Compare commits
3
Commits
08db5374cb
...
cad3867027
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cad3867027 | ||
|
|
20526e1bc8 | ||
|
|
c70b0095a9 |
@@ -0,0 +1,54 @@
|
|||||||
|
# Roadmap — fork Resenha
|
||||||
|
|
||||||
|
Plano de features próprias desta instância. Arquivo exclusivo do fork
|
||||||
|
(nome com sufixo para não colidir com arquivos do upstream em merges).
|
||||||
|
|
||||||
|
Escrito em português por ser documento de planejamento do dono do fork; o
|
||||||
|
código e os commits seguem em inglês, como o resto do repositório.
|
||||||
|
|
||||||
|
## Entregue
|
||||||
|
|
||||||
|
| Feature | Commit | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| Ir para a call clicando no nome do canal | `c70b0095` | Exigiu o `voiceStore` passar a guardar o espaço da call — antes ele não sabia onde a call estava assim que o usuário navegava para outro servidor |
|
||||||
|
| Botão de GIF redesenhado | `20526e1b` | Contorno vazado com letras cheias, no lugar do bloco sólido |
|
||||||
|
| Explorador de GIF no banner | `20526e1b` | Sem upload: banner já aceita URL absoluta no cliente e no servidor |
|
||||||
|
|
||||||
|
## Já existia no código (verificado, não construir de novo)
|
||||||
|
|
||||||
|
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
|
||||||
|
escalonados em 0/150/300ms.
|
||||||
|
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
|
||||||
|
stream started/ended, alguém entra/sai da tela, entra/sai da call, câmera,
|
||||||
|
mute, ringing. Os `.ogg` estão em `web/public/sounds/`.
|
||||||
|
|
||||||
|
Se qualquer um dos dois não se manifestar em uso, o trabalho é **depuração**,
|
||||||
|
não implementação.
|
||||||
|
|
||||||
|
## Pendente — pedido original
|
||||||
|
|
||||||
|
Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
|
||||||
|
|
||||||
|
| # | Feature | Tamanho | Observação técnica |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 7 | Preview de perfil ao clicar no nome | Média | Os dados já vêm do `UserProfileModal`; é transformar em popover ancorado |
|
||||||
|
| 10 | Teste de voz com loopback | Média | `AudioManager.playTestTone()` já existe; falta capturar o mic e devolver no monitor, com "Stop Testing" |
|
||||||
|
| 6 | Favoritar GIFs + categorias | Grande | Precisa de tabela, migração drizzle e API para sincronizar entre dispositivos, como no Discord |
|
||||||
|
| 8 | Atividade (Spotify etc.) | Grande | `activityStore` e `activityBridge` já existem, mas a detecção é via Electron; Spotify exige OAuth e presença via WebSocket |
|
||||||
|
| 9 | Registro de auditoria | Grande | Schema + ganchos em cada mutação do servidor + interface |
|
||||||
|
|
||||||
|
## Pendente — ideias aprovadas
|
||||||
|
|
||||||
|
| Feature | Tamanho | Observação técnica |
|
||||||
|
|---|---|---|
|
||||||
|
| Soundboard | Média | `AudioManager` já carrega e toca `.ogg` sob demanda; falta upload por espaço, permissão e disparo na sala LiveKit |
|
||||||
|
| Estatísticas do grupo | Grande | Horas em call, quem mais falou, ranking. **Depende do mesmo registro de eventos da auditoria (#9)** |
|
||||||
|
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
|
||||||
|
| Emojis e stickers do grupo | Média | `UPLOAD_DIR` e o pipeline de upload já existem; falta tabela por espaço e resolução no render de mensagem |
|
||||||
|
|
||||||
|
## Dependência que vale respeitar
|
||||||
|
|
||||||
|
**Auditoria (#9) e Estatísticas compartilham o mesmo mecanismo**: uma tabela de
|
||||||
|
eventos append-only no servidor. Construir a auditoria primeiro e as
|
||||||
|
estatísticas como leitura agregada dessa mesma tabela evita escrever dois
|
||||||
|
sistemas de registro paralelos que divergem com o tempo.
|
||||||
@@ -959,8 +959,14 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
title="GIF"
|
title="GIF"
|
||||||
aria-label="GIF picker"
|
aria-label="GIF picker"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
{/* Outlined badge, not a filled block: the solid rectangle read as
|
||||||
<path d="M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
|
a plain square rather than a GIF picker. Letters reuse the
|
||||||
|
original glyph paths, scaled and centred inside the outline. */}
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<rect x="3" y="6" width="18" height="12" rx="3" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<g fill="currentColor" transform="translate(-1.77 -4.2) scale(1.35)">
|
||||||
|
<path d="M5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useInstanceStore } from '../../../stores/instanceStore';
|
|||||||
import { useSpaceStore } from '../../../stores/spaceStore';
|
import { useSpaceStore } from '../../../stores/spaceStore';
|
||||||
import { Avatar } from '../../ui/Avatar';
|
import { Avatar } from '../../ui/Avatar';
|
||||||
import { ImageCropModal } from '../../ui/ImageCropModal';
|
import { ImageCropModal } from '../../ui/ImageCropModal';
|
||||||
|
import { GifPicker } from '../../chat/GifPicker';
|
||||||
import { DeleteAccountModal } from '../DeleteAccountModal';
|
import { DeleteAccountModal } from '../DeleteAccountModal';
|
||||||
import { api } from '../../../api/client';
|
import { api } from '../../../api/client';
|
||||||
import { useTransferStore } from '../../../stores/transferStore';
|
import { useTransferStore } from '../../../stores/transferStore';
|
||||||
@@ -13,6 +14,16 @@ import { getAvatarGradient, adjustColor, mutedGradient, AVATAR_GRADIENT_MAP, BAN
|
|||||||
import { AVATAR_COLORS } from '@backspace/shared';
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import type { User, UserStatus, AvatarColor } from '@backspace/shared';
|
import type { User, UserStatus, AvatarColor } from '@backspace/shared';
|
||||||
import type { FederationOpResult } from '../../../utils/federationOps';
|
import type { FederationOpResult } from '../../../utils/federationOps';
|
||||||
|
/**
|
||||||
|
* Banner/avatar previews hold either a `blob:` object URL (local upload) or a
|
||||||
|
* remote `https:` URL (GIF picker). Only the former owns memory that must be
|
||||||
|
* released — calling revokeObjectURL on a remote URL is a silent no-op that
|
||||||
|
* would quietly hide a mistake here.
|
||||||
|
*/
|
||||||
|
function releasePreview(url: string | null): void {
|
||||||
|
if (url && url.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
export function AccountPanel() {
|
export function AccountPanel() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const updateProfile = useAuthStore((s) => s.updateProfile);
|
const updateProfile = useAuthStore((s) => s.updateProfile);
|
||||||
@@ -37,6 +48,7 @@ export function AccountPanel() {
|
|||||||
const [bannerFilename, setBannerFilename] = useState<string | null>(null);
|
const [bannerFilename, setBannerFilename] = useState<string | null>(null);
|
||||||
const [uploadingBanner, setUploadingBanner] = useState(false);
|
const [uploadingBanner, setUploadingBanner] = useState(false);
|
||||||
const [bannerCropSrc, setBannerCropSrc] = useState<string | null>(null);
|
const [bannerCropSrc, setBannerCropSrc] = useState<string | null>(null);
|
||||||
|
const [showBannerGif, setShowBannerGif] = useState(false);
|
||||||
const bannerInputRef = useRef<HTMLInputElement>(null);
|
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const addToast = useUIStore((s) => s.addToast);
|
const addToast = useUIStore((s) => s.addToast);
|
||||||
@@ -54,7 +66,7 @@ export function AccountPanel() {
|
|||||||
setCustomHex(user.accentColor ?? '');
|
setCustomHex(user.accentColor ?? '');
|
||||||
// Reset upload state
|
// Reset upload state
|
||||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
releasePreview(bannerPreview);
|
||||||
setAvatarPreview(null);
|
setAvatarPreview(null);
|
||||||
setAvatarFilename(null);
|
setAvatarFilename(null);
|
||||||
setBannerPreview(null);
|
setBannerPreview(null);
|
||||||
@@ -202,7 +214,7 @@ export function AccountPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBannerCropComplete = async (blob: Blob) => {
|
const handleBannerCropComplete = async (blob: Blob) => {
|
||||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
releasePreview(bannerPreview);
|
||||||
const previewUrl = URL.createObjectURL(blob);
|
const previewUrl = URL.createObjectURL(blob);
|
||||||
setBannerPreview(previewUrl);
|
setBannerPreview(previewUrl);
|
||||||
setBannerCropSrc(null);
|
setBannerCropSrc(null);
|
||||||
@@ -227,8 +239,21 @@ export function AccountPanel() {
|
|||||||
setAvatarFilename('');
|
setAvatarFilename('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banners accept absolute URLs end to end: the server's isValidAssetUrl
|
||||||
|
* allows http(s), and the profile render already branches on
|
||||||
|
* `banner.startsWith('http')`. So a picked GIF needs no upload — the remote
|
||||||
|
* URL is stored directly.
|
||||||
|
*/
|
||||||
|
const handleBannerGifSelect = (url: string) => {
|
||||||
|
releasePreview(bannerPreview);
|
||||||
|
setBannerPreview(url);
|
||||||
|
setBannerFilename(url);
|
||||||
|
setShowBannerGif(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRemoveBanner = () => {
|
const handleRemoveBanner = () => {
|
||||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
releasePreview(bannerPreview);
|
||||||
setBannerPreview(null);
|
setBannerPreview(null);
|
||||||
setBannerFilename('');
|
setBannerFilename('');
|
||||||
};
|
};
|
||||||
@@ -300,7 +325,7 @@ export function AccountPanel() {
|
|||||||
setAvatarColorState(user.avatarColor ?? null);
|
setAvatarColorState(user.avatarColor ?? null);
|
||||||
setCustomHex(user.accentColor ?? '');
|
setCustomHex(user.accentColor ?? '');
|
||||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
releasePreview(bannerPreview);
|
||||||
setAvatarPreview(null);
|
setAvatarPreview(null);
|
||||||
setAvatarFilename(null);
|
setAvatarFilename(null);
|
||||||
setBannerPreview(null);
|
setBannerPreview(null);
|
||||||
@@ -482,7 +507,7 @@ export function AccountPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex gap-2 mt-1">
|
<div className="relative flex gap-2 mt-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => bannerInputRef.current?.click()}
|
onClick={() => bannerInputRef.current?.click()}
|
||||||
@@ -491,6 +516,23 @@ export function AccountPanel() {
|
|||||||
>
|
>
|
||||||
Change Banner
|
Change Banner
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowBannerGif((v) => !v)}
|
||||||
|
disabled={uploadingBanner}
|
||||||
|
className="text-xs text-accent-primary hover:underline"
|
||||||
|
>
|
||||||
|
Choose GIF
|
||||||
|
</button>
|
||||||
|
{showBannerGif && (
|
||||||
|
<>
|
||||||
|
{/* Click-away layer, below the panel but above the page */}
|
||||||
|
<div className="fixed inset-0 z-[290]" onClick={() => setShowBannerGif(false)} />
|
||||||
|
<div className="absolute left-0 top-full mt-2 z-[300] glass rounded-xl overflow-hidden">
|
||||||
|
<GifPicker onGifSelect={handleBannerGifSelect} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{(displayBannerSrc || user.banner) && bannerFilename !== '' && (
|
{(displayBannerSrc || user.banner) && bannerFilename !== '' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
@@ -16,6 +18,10 @@ import { handleCameraAction } from '../../utils/voiceActions';
|
|||||||
*/
|
*/
|
||||||
export function VoiceControls() {
|
export function VoiceControls() {
|
||||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||||
|
const currentVoiceSpaceId = useVoiceStore((s) => s.currentVoiceSpaceId);
|
||||||
|
const currentVoiceChannelName = useVoiceStore((s) => s.currentVoiceChannelName);
|
||||||
|
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||||
|
const navigate = useNavigate();
|
||||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||||
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
|
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
|
||||||
@@ -42,7 +48,20 @@ export function VoiceControls() {
|
|||||||
if (!currentVoiceChannelId && !activeDmCall) return null;
|
if (!currentVoiceChannelId && !activeDmCall) return null;
|
||||||
|
|
||||||
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
||||||
const channelName = channel?.name ?? (activeDmCall ? 'DM Call' : 'Voice Channel');
|
const channelName =
|
||||||
|
channel?.name ?? currentVoiceChannelName ?? (activeDmCall ? 'DM Call' : 'Voice Channel');
|
||||||
|
|
||||||
|
// Jump back to where the call is happening. DM calls live under @me; space
|
||||||
|
// calls under the space captured at join time.
|
||||||
|
const callSpaceId = activeDmCall ? '@me' : currentVoiceSpaceId;
|
||||||
|
const callChannelId = activeDmCall ? activeDmCall.dmChannelId : currentVoiceChannelId;
|
||||||
|
const canGoToCall = Boolean(callChannelId && callSpaceId && !connectionError);
|
||||||
|
|
||||||
|
const handleGoToCall = () => {
|
||||||
|
if (!canGoToCall || !callChannelId) return;
|
||||||
|
setCurrentChannel(callChannelId);
|
||||||
|
navigate(`/channels/${callSpaceId}/${callChannelId}`);
|
||||||
|
};
|
||||||
|
|
||||||
const handleScreenShare = async () => {
|
const handleScreenShare = async () => {
|
||||||
const room = getActiveRoom();
|
const room = getActiveRoom();
|
||||||
@@ -120,9 +139,19 @@ export function VoiceControls() {
|
|||||||
<div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}>
|
<div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}>
|
||||||
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
|
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[12px] text-txt-tertiary truncate leading-[16px]">
|
{canGoToCall ? (
|
||||||
{connectionError ? connectionError : channelName}
|
<button
|
||||||
</div>
|
onClick={handleGoToCall}
|
||||||
|
title="Go to call"
|
||||||
|
className="text-[12px] text-txt-tertiary truncate leading-[16px] w-full text-left hover:text-txt-primary hover:underline transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{channelName}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="text-[12px] text-txt-tertiary truncate leading-[16px]">
|
||||||
|
{connectionError ? connectionError : channelName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ export interface ScreenShareConfig {
|
|||||||
interface VoiceState {
|
interface VoiceState {
|
||||||
voiceUsers: Map<string, string[]>; // channelId → userIds
|
voiceUsers: Map<string, string[]>; // channelId → userIds
|
||||||
currentVoiceChannelId: string | null;
|
currentVoiceChannelId: string | null;
|
||||||
|
/**
|
||||||
|
* Space and name of the channel the call is in, captured at join time.
|
||||||
|
* `channels` in spaceStore only holds the space the user is *viewing*, so
|
||||||
|
* once they navigate elsewhere the call's channel is no longer resolvable
|
||||||
|
* from it — these keep the voice panel able to name and link to the call.
|
||||||
|
* Transient: intentionally absent from `partialize`.
|
||||||
|
*/
|
||||||
|
currentVoiceSpaceId: string | null;
|
||||||
|
currentVoiceChannelName: string | null;
|
||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
isDeafened: boolean;
|
isDeafened: boolean;
|
||||||
isCameraOn: boolean;
|
isCameraOn: boolean;
|
||||||
@@ -85,7 +94,7 @@ interface VoiceState {
|
|||||||
setVoiceUsers: (channelId: string, userIds: string[]) => void;
|
setVoiceUsers: (channelId: string, userIds: string[]) => void;
|
||||||
addVoiceUser: (channelId: string, userId: string) => void;
|
addVoiceUser: (channelId: string, userId: string) => void;
|
||||||
removeVoiceUser: (channelId: string, userId: string) => void;
|
removeVoiceUser: (channelId: string, userId: string) => void;
|
||||||
setCurrentVoiceChannel: (channelId: string | null) => void;
|
setCurrentVoiceChannel: (channelId: string | null, spaceId?: string | null, channelName?: string | null) => void;
|
||||||
setParticipants: (participants: ParticipantInfo[]) => void;
|
setParticipants: (participants: ParticipantInfo[]) => void;
|
||||||
setSpeakingParticipants: (ids: Set<string>) => void;
|
setSpeakingParticipants: (ids: Set<string>) => void;
|
||||||
setConnectionError: (error: string | null) => void;
|
setConnectionError: (error: string | null) => void;
|
||||||
@@ -158,6 +167,8 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
voiceUsers: new Map(),
|
voiceUsers: new Map(),
|
||||||
currentVoiceChannelId: null,
|
currentVoiceChannelId: null,
|
||||||
|
currentVoiceSpaceId: null,
|
||||||
|
currentVoiceChannelName: null,
|
||||||
isMuted: false,
|
isMuted: false,
|
||||||
pttActive: false,
|
pttActive: false,
|
||||||
isDeafened: false,
|
isDeafened: false,
|
||||||
@@ -349,8 +360,10 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
setCurrentVoiceChannel: (channelId) => set({
|
setCurrentVoiceChannel: (channelId, spaceId = null, channelName = null) => set({
|
||||||
currentVoiceChannelId: channelId,
|
currentVoiceChannelId: channelId,
|
||||||
|
currentVoiceSpaceId: channelId ? spaceId : null,
|
||||||
|
currentVoiceChannelName: channelId ? channelName : null,
|
||||||
activeDmCall: null // Clear active DM call when joining a server channel
|
activeDmCall: null // Clear active DM call when joining a server channel
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -516,6 +529,8 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
voiceUsers: new Map(),
|
voiceUsers: new Map(),
|
||||||
voiceUserStates: new Map(),
|
voiceUserStates: new Map(),
|
||||||
currentVoiceChannelId: null,
|
currentVoiceChannelId: null,
|
||||||
|
currentVoiceSpaceId: null,
|
||||||
|
currentVoiceChannelName: null,
|
||||||
participants: [],
|
participants: [],
|
||||||
speakingParticipantIds: new Set(),
|
speakingParticipantIds: new Set(),
|
||||||
speakingUserIds: new Set(),
|
speakingUserIds: new Set(),
|
||||||
|
|||||||
@@ -152,7 +152,15 @@ export function joinVoiceChannel(
|
|||||||
if (myOldId) removeVoiceUser(currentVoiceChannelId, myOldId);
|
if (myOldId) removeVoiceUser(currentVoiceChannelId, myOldId);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentVoiceChannel(channelId);
|
// Capture the space and name now: a voice channel can only be joined from
|
||||||
|
// within its own space, but the user may navigate away afterwards — at which
|
||||||
|
// point spaceStore.channels no longer resolves this channel.
|
||||||
|
const spaceStore = useSpaceStore.getState();
|
||||||
|
setCurrentVoiceChannel(
|
||||||
|
channelId,
|
||||||
|
spaceStore.currentSpaceId ?? null,
|
||||||
|
spaceStore.channels.find((c) => c.id === channelId)?.name ?? null,
|
||||||
|
);
|
||||||
// Optimistic: immediately show self in new channel (using origin-aware ID)
|
// Optimistic: immediately show self in new channel (using origin-aware ID)
|
||||||
const myNewId = getMyUserIdForOrigin(getChannelOrigin(channelId));
|
const myNewId = getMyUserIdForOrigin(getChannelOrigin(channelId));
|
||||||
if (myNewId) addVoiceUser(channelId, myNewId);
|
if (myNewId) addVoiceUser(channelId, myNewId);
|
||||||
|
|||||||
Reference in New Issue
Block a user