This commit is contained in:
legop3
2026-05-14 20:03:08 -04:00
parent c487eb134d
commit 61d88c0871
6 changed files with 239 additions and 134 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CZZyVOuh.js"></script>
<script type="module" crossorigin src="/assets/index-CfrFiCzL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CR1Y7EEc.css">
</head>
<body>
+9
View File
@@ -11,6 +11,7 @@ import VipIdentityCard from '../vip/VipIdentityCard.jsx';
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
import VipNeatoCard from '../vip/VipNeatoCard.jsx';
import VipLiftCard from '../vip/VipLiftCard.jsx';
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
export default function VipPanel({ isActive = true }) {
const session = useSessionSelector((state) => state.session);
@@ -131,6 +132,14 @@ export default function VipPanel({ isActive = true }) {
fullWidth
/>
</div>
<div className="lg:col-span-2">
<VipProfileImageCard
isVerified={isVerified}
onMessage={setMessage}
fullWidth
/>
</div>
</div>
{message ? (
@@ -0,0 +1,91 @@
// Vip Profile Image Card
// Purpose: Lets verified VIP users persist a profile image URL used for chat message avatars.
// Scope: Owns local URL validation UX and writes to the existing profile settings namespace.
import { useEffect, useMemo, useState } from 'react';
import { useSettingsNamespace } from '../../settings/index.js';
import { fieldClass, flowWrapClass, innerFlowClass } from './constants.js';
function normalizeHttpUrl(value) {
const raw = String(value || '').trim();
if (!raw) return '';
try {
const parsed = new URL(raw);
const protocol = String(parsed.protocol || '').toLowerCase();
if (protocol !== 'http:' && protocol !== 'https:') return '';
return parsed.toString();
} catch {
return '';
}
}
export default function VipProfileImageCard({ isVerified = false, fullWidth = false, onMessage }) {
const { value: profile, save: saveProfile } = useSettingsNamespace('profile', {
nickname: '',
profileImageUrl: '',
});
const stored = String(profile?.profileImageUrl || '').trim();
const [input, setInput] = useState(stored);
const [working, setWorking] = useState(false);
useEffect(() => {
setInput(stored);
}, [stored]);
const normalizedInput = useMemo(() => normalizeHttpUrl(input), [input]);
const hasUnsaved = String(input || '').trim() !== stored;
const canSave = Boolean(isVerified && hasUnsaved && (normalizedInput || !String(input || '').trim()));
const wrapClass = fullWidth ? 'w-full' : flowWrapClass;
const handleSave = async (event) => {
event.preventDefault();
if (!isVerified) {
onMessage?.('Verify your account before setting a profile image URL.');
return;
}
setWorking(true);
onMessage?.('');
try {
const next = normalizedInput || '';
saveProfile((current) => ({ ...(current || {}), profileImageUrl: next }));
onMessage?.(next ? 'Profile image URL saved.' : 'Profile image URL cleared.');
} finally {
setWorking(false);
}
};
return (
<section className={`surface text-sm text-slate-300 ${wrapClass}`}>
<form className={innerFlowClass} onSubmit={handleSave}>
<p className="text-sm text-slate-300">Chat profile image URL</p>
<p className="text-xs text-slate-500">
Verified users can set a custom avatar for chat and Discord bridge messages.
</p>
<input
className={fieldClass}
type="url"
name="vip_profile_image_url"
placeholder="https://example.com/avatar.png"
value={input}
onChange={(event) => setInput(event.target.value)}
disabled={!isVerified || working}
/>
{!normalizedInput && String(input || '').trim() ? (
<p className="text-xs text-amber-300">Enter a valid http/https image URL.</p>
) : null}
<div className="flex justify-center gap-0.5">
<button type="submit" className="button-dark text-sm disabled:opacity-50" disabled={!canSave || working}>
{working ? 'Saving...' : 'Save URL'}
</button>
<button
type="button"
className="button-dark text-sm disabled:opacity-50"
disabled={!isVerified || working || !stored}
onClick={() => setInput('')}
>
Clear
</button>
</div>
</form>
</section>
);
}
+7 -2
View File
@@ -27,6 +27,7 @@ export function ChatProvider({ children }) {
const session = useSessionSelector((state) => state.session);
const { pushAlert } = useSessionActions();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const { value: profileSettings } = useSettingsNamespace('profile', { nickname: '', profileImageUrl: '' });
const [messages, setMessages] = useState([]);
const [typing, setTyping] = useState([]);
const [isChatFocused, setIsChatFocused] = useState(false);
@@ -39,6 +40,8 @@ export function ChatProvider({ children }) {
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * alertVolume));
const isVerified = Boolean(session?.isVerified);
const profileImage = isVerified ? String(profileSettings?.profileImageUrl || '').trim() : '';
const rebuildTyping = useCallback(() => {
const entries = Array.from(typingRef.current.values())
@@ -182,7 +185,9 @@ export function ChatProvider({ children }) {
const sendMessage = useCallback(
(text, tts = null) =>
new Promise((resolve, reject) => {
socket.emit('chat:send', { text, tts }, (resp = {}) => {
const payload = { text, tts };
if (profileImage) payload.profileImage = profileImage;
socket.emit('chat:send', payload, (resp = {}) => {
if (resp.error) {
reject(new Error(resp.error));
} else {
@@ -190,7 +195,7 @@ export function ChatProvider({ children }) {
}
});
}),
[socket],
[profileImage, socket],
);
const registerInputRef = useCallback((el, options = {}) => {