feat: security hardening, DB indexes, token revocation, and input validation
- SSRF protection: DNS resolution + private IP blocking on metadata fetcher - Upload security: CSP/X-Frame-Options headers, SVG forced download, nosniff - Auth hardening: JWT secret min length, password min 8 chars, token revocation via password_changed_at - Attachment ownership verification before linking to messages - Message length limit (4000 chars) enforced on client and server - Asset URL validation on avatar/banner updates - Federation instance validation (domain regex, origin scheme, length limits) - DB indexes on all FK columns for query performance - Migrations: nullable moderator columns, dm_messages reply_to FK constraint - File cleanup on avatar/banner replacement and space deletion - Fastify trustProxy, AbortController on fetches, typing map size cap
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
interface EmbedProps {
|
||||
url: string;
|
||||
@@ -17,26 +17,28 @@ export function Embed({ url }: EmbedProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
// Simple fetch from our new API
|
||||
const controller = new AbortController();
|
||||
|
||||
const token = useAuthStore.getState().token;
|
||||
fetch(`/api/utils/metadata?url=${encodeURIComponent(url)}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('backspace_token')}`
|
||||
}
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (isMounted && data.title) {
|
||||
if (data.title) {
|
||||
setMetadata(data);
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted) setIsLoading(false);
|
||||
.catch((err) => {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => { isMounted = false; };
|
||||
return () => { controller.abort(); };
|
||||
}, [url]);
|
||||
|
||||
if (isLoading || !metadata) return null;
|
||||
@@ -50,9 +52,9 @@ export function Embed({ url }: EmbedProps) {
|
||||
</div>
|
||||
)}
|
||||
{metadata.title && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[16px] text-txt-link font-semibold hover:underline block mb-2"
|
||||
>
|
||||
@@ -67,9 +69,9 @@ export function Embed({ url }: EmbedProps) {
|
||||
</div>
|
||||
{metadata.image && (
|
||||
<div className="w-[80px] h-[80px] m-3 flex-shrink-0">
|
||||
<img
|
||||
src={metadata.image}
|
||||
alt=""
|
||||
<img
|
||||
src={metadata.image}
|
||||
alt=""
|
||||
className="w-full h-full object-cover rounded-[4px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -54,10 +54,10 @@ export function FriendsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDm = async (friendId: string, instanceOrigin: string) => {
|
||||
const handleOpenDm = async (friendId: string, instanceOrigin: string, homeUserId?: string) => {
|
||||
try {
|
||||
// Check if a DM already exists with this user (on any instance)
|
||||
const existing = useSpaceStore.getState().findExistingDmForUser({ id: friendId });
|
||||
const existing = useSpaceStore.getState().findExistingDmForUser({ id: friendId, homeUserId: homeUserId ?? undefined });
|
||||
if (existing) {
|
||||
useUIStore.getState().setShowDms(true);
|
||||
navigate(`/channels/@me/${existing.dm.id}`);
|
||||
@@ -99,7 +99,7 @@ export function FriendsPage() {
|
||||
</div>
|
||||
) : (
|
||||
onlineFriends.map(friend => (
|
||||
<FriendItem key={`${friend.id}:${friend._instanceOrigin}`} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin)} />
|
||||
<FriendItem key={`${friend.id}:${friend._instanceOrigin}`} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin, friend.homeUserId ?? undefined)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -116,7 +116,7 @@ export function FriendsPage() {
|
||||
</div>
|
||||
) : (
|
||||
friends.map(friend => (
|
||||
<FriendItem key={`${friend.id}:${friend._instanceOrigin}`} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin)} />
|
||||
<FriendItem key={`${friend.id}:${friend._instanceOrigin}`} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin, friend.homeUserId ?? undefined)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -227,7 +227,7 @@ function AddFriendTab({
|
||||
addStatus: { type: 'success' | 'error'; message: string } | null;
|
||||
isLoading: boolean;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onOpenDm: (userId: string, origin: string) => void;
|
||||
onOpenDm: (userId: string, origin: string, homeUserId?: string) => void;
|
||||
}) {
|
||||
const discoverUsers = useDiscoverStore((s) => s.users);
|
||||
const discoverLoading = useDiscoverStore((s) => s.isLoading);
|
||||
@@ -354,7 +354,7 @@ function UserDiscoverCard({
|
||||
onOpenDm,
|
||||
}: {
|
||||
user: TaggedDiscoverUser;
|
||||
onOpenDm: (userId: string, origin: string) => void;
|
||||
onOpenDm: (userId: string, origin: string, homeUserId?: string) => void;
|
||||
}) {
|
||||
const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest);
|
||||
const updateFriendRequest = useSocialStore((s) => s.updateFriendRequest);
|
||||
@@ -443,7 +443,7 @@ function UserDiscoverCard({
|
||||
};
|
||||
|
||||
const handleMessage = () => {
|
||||
onOpenDm(user.id, user._instanceOrigin);
|
||||
onOpenDm(user.id, user._instanceOrigin, user.homeUserId ?? undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -192,6 +192,8 @@ function buildComponents(): Components {
|
||||
alt={alt ?? ''}
|
||||
className="max-w-full max-h-[350px] rounded-md mt-1"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
),
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { MentionPopover } from './MentionPopover';
|
||||
import { TypingIndicator } from './TypingIndicator';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import type { MemberWithUser } from '@backspace/shared';
|
||||
import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared';
|
||||
|
||||
interface MessageInputProps {
|
||||
channelId: string;
|
||||
@@ -76,9 +76,13 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
}, 3000);
|
||||
}, [channelId]);
|
||||
|
||||
const remaining = MAX_MESSAGE_LENGTH - content.length;
|
||||
const isOverLimit = remaining < 0;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed && files.length === 0) return;
|
||||
if (isOverLimit) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setMentionState(null);
|
||||
@@ -364,6 +368,13 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Character counter (shows when near or over limit) */}
|
||||
{content.length > MAX_MESSAGE_LENGTH - 200 && (
|
||||
<span className={`text-[12px] font-medium tabular-nums flex-shrink-0 px-1 ${isOverLimit ? 'text-accent-rose' : 'text-txt-tertiary'}`}>
|
||||
{remaining}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* GIF button */}
|
||||
<button className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0" title="GIF">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
|
||||
@@ -18,7 +18,7 @@ import { MemberListToggleButton } from './MemberListToggleButton';
|
||||
import { isSelf } from '../../utils/identity';
|
||||
import { joinVoiceChannel } from '../../utils/voice';
|
||||
import { SearchPopover } from '../chat/SearchPopover';
|
||||
import { isDmChannel } from '../../stores/spaceStore';
|
||||
import { isDmChannel, getChannelOrigin } from '../../stores/spaceStore';
|
||||
|
||||
export function MainContent() {
|
||||
// 1. ALL HOOKS AT THE TOP
|
||||
@@ -93,13 +93,13 @@ export function MainContent() {
|
||||
const handleStartVoiceCall = () => {
|
||||
if (!currentChannelId) return;
|
||||
useVoiceStore.getState().setOutgoingCall({ dmChannelId: currentChannelId });
|
||||
wsSend({ type: 'dm_call_start', dmChannelId: currentChannelId });
|
||||
wsSend({ type: 'dm_call_start', dmChannelId: currentChannelId }, getChannelOrigin(currentChannelId));
|
||||
};
|
||||
|
||||
const handleCancelCall = () => {
|
||||
if (!currentChannelId) return;
|
||||
useVoiceStore.getState().setOutgoingCall(null);
|
||||
wsSend({ type: 'dm_call_end', dmChannelId: currentChannelId });
|
||||
wsSend({ type: 'dm_call_end', dmChannelId: currentChannelId }, getChannelOrigin(currentChannelId));
|
||||
};
|
||||
|
||||
if (isInDmCall) {
|
||||
|
||||
@@ -321,6 +321,11 @@ export function UserProfileModal() {
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{user.bio}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -135,6 +135,11 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{user.bio}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { getAvatarGradient } from '../../utils/gradients';
|
||||
import { parseFederatedUsername } from '../../utils/identity';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
|
||||
export function IncomingCallModal() {
|
||||
const incomingCall = useVoiceStore((s) => s.incomingCall);
|
||||
@@ -15,7 +15,7 @@ export function IncomingCallModal() {
|
||||
if (incomingCall) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
// Auto-reject after timeout
|
||||
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId });
|
||||
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId }, getChannelOrigin(incomingCall.dmChannelId));
|
||||
setIncomingCall(null);
|
||||
}, 30000);
|
||||
}
|
||||
@@ -39,12 +39,12 @@ export function IncomingCallModal() {
|
||||
|
||||
const handleAccept = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
wsSend({ type: 'dm_call_accept', dmChannelId: incomingCall.dmChannelId });
|
||||
wsSend({ type: 'dm_call_accept', dmChannelId: incomingCall.dmChannelId }, getChannelOrigin(incomingCall.dmChannelId));
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId });
|
||||
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId }, getChannelOrigin(incomingCall.dmChannelId));
|
||||
setIncomingCall(null);
|
||||
};
|
||||
|
||||
@@ -54,26 +54,29 @@ export function IncomingCallModal() {
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
|
||||
{/* Call card */}
|
||||
<div className="relative glass-modal rounded-lg w-[340px] overflow-hidden">
|
||||
{/* Ring animation background */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[200px] h-[200px] rounded-full bg-status-online/5 animate-ping" style={{ animationDuration: '2s' }} />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[150px] h-[150px] rounded-full bg-status-online/10 animate-ping" style={{ animationDuration: '2s', animationDelay: '0.5s' }} />
|
||||
<div className="relative glass-modal rounded-lg w-[340px] overflow-hidden animate-fade-in animate-slide-up">
|
||||
{/* Ripple rings */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div
|
||||
className="absolute top-1/2 left-1/2 w-[160px] h-[160px] rounded-full border border-status-online/30 animate-call-ripple"
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 left-1/2 w-[160px] h-[160px] rounded-full border border-status-online/30 animate-call-ripple"
|
||||
style={{ animationDelay: '1.5s' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative p-8 flex flex-col items-center gap-4">
|
||||
{/* Caller avatar */}
|
||||
<div className="relative">
|
||||
<div className="w-20 h-20 rounded-full flex items-center justify-center text-white text-3xl font-bold" style={{ background: getAvatarGradient(callerAvatarId, callerBaseName).gradient }}>
|
||||
{callerBaseName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/* Ringing phone icon */}
|
||||
<div className="absolute -bottom-1 -right-1 w-7 h-7 rounded-full bg-status-online flex items-center justify-center">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="rounded-full animate-call-glow">
|
||||
<Avatar
|
||||
src={callerMember?.avatar}
|
||||
avatarColor={callerMember?.avatarColor}
|
||||
userId={callerAvatarId}
|
||||
name={callerBaseName}
|
||||
size={80}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Caller info */}
|
||||
@@ -87,10 +90,10 @@ export function IncomingCallModal() {
|
||||
{/* Decline */}
|
||||
<button
|
||||
onClick={handleDecline}
|
||||
className="w-14 h-14 rounded-full bg-accent-rose hover:bg-accent-rose/80 flex items-center justify-center transition-colors group"
|
||||
className="w-14 h-14 rounded-full bg-accent-rose/20 border border-accent-rose/30 backdrop-blur-sm flex items-center justify-center transition-all duration-200 hover:bg-accent-rose/35 group"
|
||||
title="Decline"
|
||||
>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="white" className="group-hover:scale-110 transition-transform">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor" className="text-accent-rose group-hover:scale-110 transition-transform">
|
||||
<path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.17-.29-.42-.29-.7 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -98,10 +101,10 @@ export function IncomingCallModal() {
|
||||
{/* Accept */}
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
className="w-14 h-14 rounded-full bg-status-online hover:bg-status-online/80 flex items-center justify-center transition-colors group"
|
||||
className="w-14 h-14 rounded-full bg-status-online/20 border border-status-online/30 backdrop-blur-sm flex items-center justify-center transition-all duration-200 hover:bg-status-online/35 animate-call-button-glow group"
|
||||
title="Accept"
|
||||
>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="white" className="group-hover:scale-110 transition-transform">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor" className="text-status-online group-hover:scale-110 transition-transform">
|
||||
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -105,7 +105,7 @@ export function VoiceControlBar() {
|
||||
const handleDisconnect = () => {
|
||||
const { activeDmCall } = useVoiceStore.getState();
|
||||
if (activeDmCall) {
|
||||
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }); // DM calls are home-only
|
||||
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, getChannelOrigin(activeDmCall.dmChannelId));
|
||||
useVoiceStore.getState().setActiveDmCall(null);
|
||||
} else {
|
||||
wsSend({ type: 'voice_leave' }, voiceOrigin);
|
||||
|
||||
@@ -76,7 +76,7 @@ export function VoiceControls() {
|
||||
const handleDisconnect = () => {
|
||||
const { activeDmCall } = useVoiceStore.getState();
|
||||
if (activeDmCall) {
|
||||
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }); // DM calls are home-only
|
||||
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, getChannelOrigin(activeDmCall.dmChannelId));
|
||||
useVoiceStore.getState().setActiveDmCall(null);
|
||||
} else {
|
||||
wsSend({ type: 'voice_leave' }, voiceOrigin);
|
||||
|
||||
@@ -387,7 +387,7 @@ export function useLiveKit() {
|
||||
}
|
||||
|
||||
try {
|
||||
const client = isDm ? getApiForOrigin('') : getApiForOrigin(getChannelOrigin(channelId));
|
||||
const client = getApiForOrigin(getChannelOrigin(channelId));
|
||||
const { token, url } = isDm ? await client.livekit.dmToken(channelId) : await client.livekit.token(channelId);
|
||||
if (gen !== _connectGeneration) return;
|
||||
const newRoom = new Room({ adaptiveStream: true, dynacast: true, publishDefaults: { videoCodec: 'h264', simulcast: true } });
|
||||
|
||||
@@ -238,8 +238,8 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Restore DM call state from server (home only)
|
||||
if (isHome) {
|
||||
// Restore DM call state from server (all origins — federated DMs live on remote instances)
|
||||
{
|
||||
const { activeDmCall, setActiveDmCall, setIncomingCall, incomingCall } = useVoiceStore.getState();
|
||||
const myId = event.user.id;
|
||||
if (event.activeCalls && event.activeCalls.length > 0) {
|
||||
@@ -521,10 +521,9 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
// ─── DM call events (home-only) ─────────────────────────────────────────
|
||||
// ─── DM call events (all origins) ──────────────────────────────────────
|
||||
|
||||
case 'dm_call_incoming': {
|
||||
if (!isHome) break;
|
||||
const { setIncomingCall } = useVoiceStore.getState();
|
||||
setIncomingCall({
|
||||
dmChannelId: event.dmChannelId,
|
||||
@@ -535,7 +534,6 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
}
|
||||
|
||||
case 'dm_call_accepted': {
|
||||
if (!isHome) break;
|
||||
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState();
|
||||
setIncomingCall(null);
|
||||
setOutgoingCall(null);
|
||||
@@ -544,7 +542,6 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
}
|
||||
|
||||
case 'dm_call_rejected': {
|
||||
if (!isHome) break;
|
||||
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState();
|
||||
setIncomingCall(null);
|
||||
setOutgoingCall(null);
|
||||
@@ -553,7 +550,6 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
}
|
||||
|
||||
case 'dm_call_ended': {
|
||||
if (!isHome) break;
|
||||
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState();
|
||||
setIncomingCall(null);
|
||||
setOutgoingCall(null);
|
||||
|
||||
@@ -595,14 +595,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
updateUserInMessages: (user: { id: string; [key: string]: any }) => {
|
||||
updateUserInMessages: (user: { id: string; homeUserId?: string | null; [key: string]: any }) => {
|
||||
set((state) => {
|
||||
const newMessages = new Map(state.messages);
|
||||
let changed = false;
|
||||
for (const [channelId, msgs] of newMessages) {
|
||||
let channelChanged = false;
|
||||
const updated = msgs.map(m => {
|
||||
if (m.userId === user.id) {
|
||||
const matches = m.userId === user.id ||
|
||||
(user.homeUserId && m.user?.homeUserId && m.user.homeUserId === user.homeUserId);
|
||||
if (matches) {
|
||||
channelChanged = true;
|
||||
return { ...m, user: { ...m.user, ...user } };
|
||||
}
|
||||
|
||||
@@ -242,8 +242,9 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
||||
if (result.status !== 'fulfilled') return;
|
||||
const origin = searches[i]!.origin;
|
||||
for (const user of result.value) {
|
||||
if (seen.has(user.id)) continue;
|
||||
seen.add(user.id);
|
||||
const dedupeKey = `${origin ?? ''}:${user.id}`;
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
if (origin) normalizeUserAssets(user, origin);
|
||||
allUsers.push(user);
|
||||
}
|
||||
|
||||
@@ -527,7 +527,7 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
}),
|
||||
{
|
||||
name: 'backspace-voice-settings',
|
||||
version: 8,
|
||||
version: 9,
|
||||
migrate: (persistedState: any, version: number) => {
|
||||
if (version === 0) {
|
||||
persistedState.streamAttenuationEnabled = false;
|
||||
@@ -563,6 +563,9 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
if (version < 8) {
|
||||
persistedState.soundEffectVolume = 100;
|
||||
}
|
||||
if (version < 9) {
|
||||
delete persistedState.currentVoiceChannelId;
|
||||
}
|
||||
return persistedState;
|
||||
},
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
@@ -570,7 +573,6 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
// noiseSuppression is intentionally excluded — always true internally,
|
||||
// managed automatically by AudioManager based on RNNoise state.
|
||||
partialize: (state) => ({
|
||||
currentVoiceChannelId: state.currentVoiceChannelId,
|
||||
isMuted: state.isMuted,
|
||||
isDeafened: state.isDeafened,
|
||||
inputVolume: state.inputVolume,
|
||||
|
||||
@@ -348,3 +348,37 @@
|
||||
.animate-step-back {
|
||||
animation: stepBack 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* ── Incoming Call Animations ── */
|
||||
@keyframes callRipple {
|
||||
0% { transform: translate(-50%, -50%) scale(0.8); opacity: 0.6; }
|
||||
100% { transform: translate(-50%, -50%) scale(1.8); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes callGlow {
|
||||
0%, 100% { box-shadow: 0 0 20px rgba(134, 239, 172, 0.15); }
|
||||
50% { box-shadow: 0 0 30px rgba(134, 239, 172, 0.25); }
|
||||
}
|
||||
|
||||
@keyframes callButtonGlow {
|
||||
0%, 100% { box-shadow: 0 0 12px rgba(134, 239, 172, 0.15); }
|
||||
50% { box-shadow: 0 0 20px rgba(134, 239, 172, 0.3); }
|
||||
}
|
||||
|
||||
.animate-call-ripple {
|
||||
animation: callRipple 3s ease-out infinite;
|
||||
}
|
||||
|
||||
.animate-call-glow {
|
||||
animation: callGlow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-call-button-glow {
|
||||
animation: callButtonGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-call-ripple,
|
||||
.animate-call-glow,
|
||||
.animate-call-button-glow { animation: none !important; }
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ async function pushProfileToRemote(inst: ConnectedInstance, homeUser: NonNullabl
|
||||
try {
|
||||
const blob = await downloadAsset(homeUser.avatar);
|
||||
const attachment = await inst.api.uploads.upload(new File([blob], homeUser.avatar));
|
||||
payload.avatar = attachment.filename;
|
||||
payload.avatar = `/api/uploads/${attachment.filename}`;
|
||||
} catch (err) {
|
||||
console.warn('[ProfileSync] Failed to upload avatar to remote:', err);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ async function pushProfileToRemote(inst: ConnectedInstance, homeUser: NonNullabl
|
||||
try {
|
||||
const blob = await downloadAsset(homeUser.banner);
|
||||
const attachment = await inst.api.uploads.upload(new File([blob], homeUser.banner));
|
||||
payload.banner = attachment.filename;
|
||||
payload.banner = `/api/uploads/${attachment.filename}`;
|
||||
} catch (err) {
|
||||
console.warn('[ProfileSync] Failed to upload banner to remote:', err);
|
||||
}
|
||||
@@ -108,7 +108,7 @@ async function pullProfileFromRemote(inst: ConnectedInstance): Promise<void> {
|
||||
try {
|
||||
const blob = await downloadAsset(remoteUser.avatar, inst.origin);
|
||||
const attachment = await api.uploads.upload(new File([blob], remoteUser.avatar.split('/').pop() || 'avatar'));
|
||||
payload.avatar = attachment.filename;
|
||||
payload.avatar = `/api/uploads/${attachment.filename}`;
|
||||
} catch (err) {
|
||||
console.warn('[ProfileSync] Failed to download/upload avatar from remote:', err);
|
||||
}
|
||||
@@ -121,7 +121,7 @@ async function pullProfileFromRemote(inst: ConnectedInstance): Promise<void> {
|
||||
try {
|
||||
const blob = await downloadAsset(remoteUser.banner, inst.origin);
|
||||
const attachment = await api.uploads.upload(new File([blob], remoteUser.banner.split('/').pop() || 'banner'));
|
||||
payload.banner = attachment.filename;
|
||||
payload.banner = `/api/uploads/${attachment.filename}`;
|
||||
} catch (err) {
|
||||
console.warn('[ProfileSync] Failed to download/upload banner from remote:', err);
|
||||
}
|
||||
@@ -206,7 +206,7 @@ export async function syncProfileUpdateToRemotes(update: Partial<UpdateUserReque
|
||||
if (avatarBlob && avatarFilename) {
|
||||
try {
|
||||
const attachment = await inst.api.uploads.upload(new File([avatarBlob], avatarFilename));
|
||||
perInstPayload.avatar = attachment.filename;
|
||||
perInstPayload.avatar = `/api/uploads/${attachment.filename}`;
|
||||
} catch (err) {
|
||||
console.warn(`[ProfileSync] Failed to upload avatar to ${inst.origin}:`, err);
|
||||
}
|
||||
@@ -220,7 +220,7 @@ export async function syncProfileUpdateToRemotes(update: Partial<UpdateUserReque
|
||||
if (bannerBlob && bannerFilename) {
|
||||
try {
|
||||
const attachment = await inst.api.uploads.upload(new File([bannerBlob], bannerFilename));
|
||||
perInstPayload.banner = attachment.filename;
|
||||
perInstPayload.banner = `/api/uploads/${attachment.filename}`;
|
||||
} catch (err) {
|
||||
console.warn(`[ProfileSync] Failed to upload banner to ${inst.origin}:`, err);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user