Compare commits

..

2 Commits

Author SHA1 Message Date
jared fb66c0ed90 fix(privacy): sanitize console error/warn to prevent PII leakage
CI / Build & Quality Checks (push) Successful in 10m39s
CI / Trigger Desktop Build (push) Successful in 7s
Replace raw error object logging (which may contain Matrix event
payloads, user IDs, or message bodies) with e.message-only strings
in three files:
- CallEmbed.ts: state update and event widget feed errors
- msgContent.ts: image/video element load failures and thumb errors
- RoomInput.tsx: GIF send failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 19:24:52 -04:00
jared 9deeef6e8d fix(pip): correctly identify whose mic is muted in PiP overlay
Previously PipMuteOverlay fired on useRemoteAllMuted (any remote
muted) and rendered in the bottom-left corner — the conventional
position for local-user mic status — causing users to think their
own mic was muted when it wasn't.

Fix: split into two distinct indicators
- Bottom-left: local mic muted only (from useCallControlState),
  labelled "You" so attribution is unambiguous
- Top-right: "All muted" warning (warning color, not critical) when
  all remote participants are muted

UNTESTED — verify in a real call at chat.lotusguild.org.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 19:23:12 -04:00
5 changed files with 72 additions and 32 deletions
+13
View File
@@ -8,6 +8,19 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
## 🚩 Critical & UI Bugs ## 🚩 Critical & UI Bugs
### 12. PiP Mute Icon Misidentifies Whose Mic Is Muted
- **File:** `cinny/src/app/components/CallEmbedProvider.tsx`
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification in a live call with at least one other participant who mutes/unmutes
- **Issue:** The muted-mic badge in the Picture-in-Picture window used `useRemoteAllMuted` (fires when ANY remote participant is muted) and rendered in the bottom-left corner — the conventional position for "YOUR" mic status. Users read it as their own mic being muted.
- **Root Cause:** `PipMuteOverlay` was triggering on remote-mute events while displaying in a position that implies local-user status.
- **Fix Applied:**
- **Bottom-left badge** now shows only when the LOCAL user's mic is muted (checked via `!controlState.microphone` from `useCallControlState`). Includes "You" label to make it unambiguous. Uses `color.Critical.Main`.
- **Top-right badge** (new) shows "All muted" in `color.Warning.Main` when all remote participants are muted — positioned and labeled so it's clearly about other people, not the local user.
- Both badges use `aria-label` / `title` for accessibility.
---
### 1. No Camera Focus During Screenshare ### 1. No Camera Focus During Screenshare
- **File:** `cinny/src/app/features/call/CallControls.tsx` - **File:** `cinny/src/app/features/call/CallControls.tsx`
+53 -26
View File
@@ -419,34 +419,61 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
return null; return null;
} }
/** Shown inside the PiP window when the local microphone is muted. */ /**
* PiP status indicators:
* - Bottom-left badge: local mic muted (matches Discord/Slack convention — bottom-left = "your" mic)
* - Top-right badge: all remote participants are muted (quiet room warning)
*
* Deliberately separated so users never mistake remote-mute state for their own.
*/
function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) { function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
const allMuted = useRemoteAllMuted(callEmbed); const mx = useMatrixClient();
if (!allMuted) return null; const controlState = useCallControlState(callEmbed.control);
const allRemoteMuted = useRemoteAllMuted(callEmbed);
const localMicMuted = !controlState.microphone;
const localUserId = mx.getSafeUserId();
const localDisplayName = getMxIdLocalPart(localUserId) ?? localUserId;
const badgeStyle: React.CSSProperties = {
position: 'absolute',
zIndex: 3,
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(4px)',
borderRadius: '6px',
padding: '3px 7px',
display: 'flex',
alignItems: 'center',
gap: '4px',
pointerEvents: 'none',
fontSize: '12px',
lineHeight: 1,
userSelect: 'none',
};
return ( return (
<div <>
aria-label="Microphone muted" {localMicMuted && (
style={{ <div
position: 'absolute', aria-label={`Your microphone is muted (${localDisplayName})`}
bottom: '8px', title={`Your microphone is muted`}
left: '8px', style={{ ...badgeStyle, bottom: '8px', left: '8px', color: color.Critical.Main }}
zIndex: 3, >
background: 'rgba(0,0,0,0.60)', <Icon size="100" src={Icons.MicMute} filled />
backdropFilter: 'blur(4px)', <span style={{ fontSize: '11px', fontWeight: 600 }}>You</span>
borderRadius: '6px', </div>
padding: '3px 7px', )}
display: 'flex', {allRemoteMuted && (
alignItems: 'center', <div
gap: '4px', aria-label="All other participants are muted"
pointerEvents: 'none', title="All other participants are muted"
color: color.Critical.Main, style={{ ...badgeStyle, top: '8px', right: '8px', color: color.Warning.Main }}
fontSize: '13px', >
lineHeight: 1, <Icon size="50" src={Icons.MicMute} />
userSelect: 'none', <span style={{ fontSize: '11px' }}>All muted</span>
}} </div>
> )}
<Icon size="100" src={Icons.MicMute} filled /> </>
</div>
); );
} }
+1 -1
View File
@@ -725,7 +725,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
info: { mimetype: 'image/gif', w, h, size: blob.size }, info: { mimetype: 'image/gif', w, h, size: blob.size },
}); });
} catch (e) { } catch (e) {
console.error('GIF send failed', e); console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error');
if (!alive()) return; if (!alive()) return;
setGifError('Failed to send GIF. Please try again.'); setGifError('Failed to send GIF. Please try again.');
setTimeout(() => setGifError(null), 4000); setTimeout(() => setGifError(null), 4000);
+3 -3
View File
@@ -50,7 +50,7 @@ export const getImageMsgContent = async (
): Promise<IContent> => { ): Promise<IContent> => {
const { file, originalFile, encInfo, metadata } = item; const { file, originalFile, encInfo, metadata } = item;
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile))); const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
if (imgError) console.warn(imgError); if (imgError) console.warn('Failed to load image element:', imgError.message);
const content: IContent = { const content: IContent = {
msgtype: MsgType.Image, msgtype: MsgType.Image,
@@ -85,7 +85,7 @@ export const getVideoMsgContent = async (
const { file, originalFile, encInfo, metadata } = item; const { file, originalFile, encInfo, metadata } = item;
const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile))); const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
if (videoError) console.warn(videoError); if (videoError) console.warn('Failed to load video element:', videoError.message);
const content: IContent = { const content: IContent = {
msgtype: MsgType.Video, msgtype: MsgType.Video,
@@ -109,7 +109,7 @@ export const getVideoMsgContent = async (
scaleYDimension(videoEl.videoWidth, 512, videoEl.videoHeight), scaleYDimension(videoEl.videoWidth, 512, videoEl.videoHeight),
); );
} }
if (thumbError) console.warn(thumbError); if (thumbError) console.warn('Failed to generate video thumbnail:', thumbError.message);
content.info = { content.info = {
...getVideoInfo(videoEl, file), ...getVideoInfo(videoEl, file),
...thumbContent, ...thumbContent,
+2 -2
View File
@@ -390,7 +390,7 @@ export class CallEmbed {
if (this.call === null) return; if (this.call === null) return;
const raw = ev.getEffectiveEvent(); const raw = ev.getEffectiveEvent();
this.call.feedStateUpdate(raw as IRoomEvent).catch((e) => { this.call.feedStateUpdate(raw as IRoomEvent).catch((e) => {
console.error('Error sending state update to widget: ', e); console.error('Error sending state update to widget:', e instanceof Error ? e.message : 'unknown error');
}); });
} }
@@ -496,7 +496,7 @@ export class CallEmbed {
} else { } else {
const raw = ev.getEffectiveEvent(); const raw = ev.getEffectiveEvent();
this.call.feedEvent(raw as IRoomEvent).catch((e) => { this.call.feedEvent(raw as IRoomEvent).catch((e) => {
console.error('Error sending event to widget: ', e); console.error('Error sending event to widget:', e instanceof Error ? e.message : 'unknown error');
}); });
} }
} }