10 Commits

17 changed files with 238 additions and 48 deletions
+2 -3
View File
@@ -37,9 +37,8 @@ jobs:
with: { node-version: 20 }
- name: Install Rust
run: |
where rustc 2>nul || (
curl -L -o rustup-init.exe https://win.rustup.rs/x86_64 && rustup-init.exe -y --default-toolchain stable
)
powershell -ExecutionPolicy Bypass -Command "Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe"
rustup-init.exe -y --default-toolchain stable
set PATH=%USERPROFILE%\.cargo\bin;%PATH%
rustc --version
shell: cmd
+7
View File
@@ -243,6 +243,13 @@ func main() {
notification.NewHandler(database.DB, logger).RegisterRoutes(r)
})
// Calendar events
calHandler := channel.NewHandler(database.DB, permissionsChecker)
r.Route("/channels/{channelID}/events", func(r chi.Router) {
r.Get("/", calHandler.ListEvents)
r.Post("/", calHandler.CreateEvent)
})
// Push notifications
pushHandler.RegisterRoutes(r)
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -39,7 +39,7 @@ type createEventRequest struct {
Color *string `json:"color"`
}
func (h *Handler) registerCalendarRoutes(r chi.Router) {
func (h *Handler) RegisterCalendarRoutes(r chi.Router) {
r.Get("/{channelID}/events", h.ListEvents)
r.Post("/{channelID}/events", h.CreateEvent)
r.Patch("/events/{eventID}", h.UpdateEvent)
+1 -1
View File
@@ -31,7 +31,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/{channelID}/threads", h.ListThreads)
r.Patch("/threads/{threadID}", h.UpdateThread)
h.registerForumRoutes(r)
h.registerCalendarRoutes(r)
h.RegisterCalendarRoutes(r)
h.registerDocRoutes(r)
h.registerListRoutes(r)
h.registerOverrideRoutes(r)
+12
View File
@@ -155,6 +155,18 @@ func (c *Client) readPump() {
c.Hub.BroadcastEvent(Event{Type: event.Type, Data: data})
case EventPresenceUpdate:
c.Hub.BroadcastEvent(event)
case EventVoiceJoin, EventVoiceLeave, EventVoiceMute, EventVoiceDeafen:
// Broadcast voice state changes to all clients with sender info
var vData map[string]interface{}
if raw, ok := event.Data.(json.RawMessage); ok {
json.Unmarshal(raw, &vData)
}
if vData == nil {
vData = make(map[string]interface{})
}
vData["user_id"] = c.UserID
vData["username"] = c.Username
c.Hub.BroadcastEvent(Event{Type: event.Type, Data: vData})
case EventVoiceWhisper:
// Forward voice whispers only to the target user, not broadcast
var whisperData struct {
+1 -1
View File
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "app"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"log",
"serde",
+56 -29
View File
@@ -1,8 +1,26 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useVoiceStore } from '../stores/voice.ts';
import { Track } from 'livekit-client';
import { Track, RoomEvent } from 'livekit-client';
import type { Participant, TrackPublication, Room } from 'livekit-client';
// ponytail: global set of audio elements blocked by autoplay policy
const pendingAudio = new Set<HTMLAudioElement>();
function flushPendingAudio() {
for (const el of pendingAudio) {
el.play().catch(() => {});
pendingAudio.delete(el);
}
}
let listenerAttached = false;
function ensureInteractionListener() {
if (listenerAttached) return;
listenerAttached = true;
document.addEventListener('click', flushPendingAudio, { once: false });
document.addEventListener('keydown', flushPendingAudio, { once: false });
}
function RemoteAudioTrack({ participant, room }: { participant: Participant; room: Room }) {
const audioRef = useRef<HTMLAudioElement>(null);
@@ -12,44 +30,35 @@ function RemoteAudioTrack({ participant, room }: { participant: Participant; roo
let pub: TrackPublication | undefined;
const attachTrack = () => {
const tryPlay = () => {
el.play().catch(() => {
pendingAudio.add(el);
ensureInteractionListener();
});
};
const attachIfReady = () => {
pub = participant.getTrackPublication(Track.Source.Microphone);
if (pub?.track && el) {
pub.track.attach(el);
el.play().catch(() => {});
tryPlay();
}
};
const detachTrack = () => {
if (pub?.track && el) {
pub.track.detach(el);
}
};
attachIfReady();
attachTrack();
const handlePublished = (publication: TrackPublication) => {
if (publication.source === Track.Source.Microphone && publication.track) {
publication.track.attach(el);
el.play().catch(() => {});
}
};
room.on('trackPublished' as any, handlePublished);
// If a track is subscribed later
const handleSubscribed = (track: Track, publication: TrackPublication, trackParticipant: Participant) => {
if (trackParticipant.identity === participant.identity && publication.source === Track.Source.Microphone) {
const handleSubscribed = (track: Track, _pub: TrackPublication, p: Participant) => {
if (p.identity === participant.identity && _pub.source === Track.Source.Microphone) {
track.attach(el);
el.play().catch(() => {});
tryPlay();
}
};
room.on('trackSubscribed' as any, handleSubscribed);
room.on(RoomEvent.TrackSubscribed, handleSubscribed);
return () => {
detachTrack();
room.off('trackPublished' as any, handlePublished);
room.off('trackSubscribed' as any, handleSubscribed);
if (pub?.track) pub.track.detach(el);
pendingAudio.delete(el);
room.off(RoomEvent.TrackSubscribed, handleSubscribed);
};
}, [participant, room]);
@@ -58,7 +67,25 @@ function RemoteAudioTrack({ participant, room }: { participant: Participant; roo
export function AudioRenderers() {
const room = useVoiceStore((s) => s._room);
// ponytail: use a counter that increments on every participant change
// zustand's Object.is check on the participants array wasn't triggering re-renders
const [, setTick] = useState(0);
useEffect(() => {
if (!room) return;
const bump = () => setTick((t) => t + 1);
room.on(RoomEvent.ParticipantConnected, bump);
room.on(RoomEvent.ParticipantDisconnected, bump);
room.on(RoomEvent.TrackSubscribed, bump);
room.on(RoomEvent.TrackUnsubscribed, bump);
return () => {
room.off(RoomEvent.ParticipantConnected, bump);
room.off(RoomEvent.ParticipantDisconnected, bump);
room.off(RoomEvent.TrackSubscribed, bump);
room.off(RoomEvent.TrackUnsubscribed, bump);
};
}, [room]);
if (!room) return null;
const remoteParticipants = Array.from(room.remoteParticipants.values());
+5 -1
View File
@@ -49,7 +49,11 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
const to = `${date.getFullYear()}-${String(date.getMonth() + 2).padStart(2, '0')}-01T00:00:00Z`;
setLoading(true);
api.get<CalendarEvent[]>(`/channels/${channelId}/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
.then((data) => setEvents(Array.isArray(data) ? data : []))
.then((data) => {
console.log('[calendar] loaded', data?.length, 'events for channel', channelId);
setEvents(Array.isArray(data) ? data : []);
})
.catch((err) => console.error('[calendar] fetch error:', err))
.finally(() => setLoading(false));
}, [channelId, date]);
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useVoiceStore } from '../stores/voice.ts';
import { saveDevicePref } from '../stores/voice.ts';
interface Props {
onClose: () => void;
@@ -48,6 +49,7 @@ export function DeviceSettingsModal({ onClose }: Props) {
if (!room) return;
try {
await room.switchActiveDevice(kind, deviceId);
saveDevicePref(kind, deviceId);
if (kind === 'audioinput') setActiveAudioInput(deviceId);
if (kind === 'audiooutput') setActiveAudioOutput(deviceId);
if (kind === 'videoinput') setActiveVideoInput(deviceId);
+17 -10
View File
@@ -2,6 +2,7 @@ import { useVoiceStore } from '../stores/voice.ts';
import { useAuthStore } from '../stores/auth.ts';
import { usePermissions } from '../lib/usePermissions.ts';
import { useChannelStore } from '../stores/channel.ts';
import { useVoicePresenceStore } from '../stores/voicePresence.ts';
import { api } from '../lib/api.ts';
interface VoiceChannelProps {
@@ -13,7 +14,8 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
const currentRoom = useVoiceStore((state) => state.currentRoom);
const isConnected = useVoiceStore((state) => state.isConnected);
const joinVoice = useVoiceStore((state) => state.joinVoice);
const participants = useVoiceStore((state) => state.participants);
const liveParticipants = useVoiceStore((state) => state.participants);
const presenceParticipants = useVoicePresenceStore((s) => s.getParticipants(channelId));
const currentUserId = useAuthStore((state) => state.user?.id);
const activeChannelId = useChannelStore((s) => s.activeChannelId);
@@ -40,6 +42,11 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
const isActive = currentRoom === channelId;
// Use live participants when in the room, otherwise fall back to presence
const displayParticipants = isActive && isConnected
? liveParticipants.map(p => ({ userId: p.identity, username: p.username, isMuted: p.isMuted, isSpeaking: p.isSpeaking, hasVideo: p.hasVideo, isScreenSharing: p.isScreenSharing }))
: presenceParticipants.map(p => ({ userId: p.userId, username: p.username, isMuted: true, isSpeaking: false, hasVideo: false, isScreenSharing: false }));
const handleClick = () => {
if (!isActive) {
console.log("[VoiceChannel] joining voice:", channelId, channelName);
@@ -69,19 +76,19 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
🔊
</span>
<span className="truncate">{channelName}</span>
{isActive && participants.length > 0 && (
{displayParticipants.length > 0 && (
<span className="ml-auto text-xxs text-gb-fg-f">
[{participants.length}]
[{displayParticipants.length}]
</span>
)}
</button>
{isActive && (
{displayParticipants.length > 0 && (
<div className="pl-7 py-0.5">
{participants.map((p) => {
const isMe = p.identity === currentUserId;
{displayParticipants.map((p) => {
const isMe = p.userId === currentUserId;
return (
<div
key={p.identity}
key={p.userId}
className="text-xxs text-gb-fg-f flex items-center gap-1 group"
>
<span className={p.isSpeaking ? 'text-gb-green' : 'text-gb-fg-f'}>
@@ -93,12 +100,12 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
{p.isScreenSharing && <span className="text-gb-aqua" title="Sharing screen">🖥</span>}
{p.hasVideo && <span className="text-gb-orange" title="Camera on">🎥</span>}
{!isMe && (
{!isMe && isActive && (
<div className="ml-auto opacity-0 group-hover:opacity-100 flex items-center gap-1">
{canMute && !p.isMuted && (
<button
className="text-gb-fg-f hover:text-gb-red"
onClick={(e) => { e.stopPropagation(); handleMute(p.identity); }}
onClick={(e) => { e.stopPropagation(); handleMute(p.userId); }}
title={`Mute ${p.username}`}
>
[mute]
@@ -106,7 +113,7 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
)}
<button
className="text-gb-fg-f hover:text-gb-orange"
onClick={(e) => { e.stopPropagation(); whisperTo(p.identity, p.username); }}
onClick={(e) => { e.stopPropagation(); whisperTo(p.userId, p.username); }}
title={`Whisper to ${p.username}`}
>
[whisper]
+72 -1
View File
@@ -64,6 +64,42 @@ interface VoiceState {
dismissWhisper: (timestamp: number) => void;
}
const DEVICE_PREFS_KEY = 'dc_voice_device_prefs';
interface DevicePrefs {
audioInput?: string;
audioOutput?: string;
videoInput?: string;
}
function loadDevicePrefs(): DevicePrefs {
try {
return JSON.parse(localStorage.getItem(DEVICE_PREFS_KEY) || '{}');
} catch {
return {};
}
}
function saveDevicePrefs(prefs: DevicePrefs) {
localStorage.setItem(DEVICE_PREFS_KEY, JSON.stringify(prefs));
}
export function saveDevicePref(kind: MediaDeviceKind, deviceId: string) {
const prefs = loadDevicePrefs();
if (kind === 'audioinput') prefs.audioInput = deviceId;
else if (kind === 'audiooutput') prefs.audioOutput = deviceId;
else if (kind === 'videoinput') prefs.videoInput = deviceId;
saveDevicePrefs(prefs);
}
export function getSavedDeviceId(kind: MediaDeviceKind): string | undefined {
const prefs = loadDevicePrefs();
if (kind === 'audioinput') return prefs.audioInput;
if (kind === 'audiooutput') return prefs.audioOutput;
if (kind === 'videoinput') return prefs.videoInput;
return undefined;
}
function participantToVoice(p: Participant): VoiceParticipant {
const micPub = p.getTrackPublication(Track.Source.Microphone);
const camPub = p.getTrackPublication(Track.Source.Camera);
@@ -138,7 +174,8 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
syncParticipants();
});
room.on(RoomEvent.Disconnected, () => {
room.on(RoomEvent.Disconnected, (reason?: any) => {
console.warn('[voice] disconnected, reason:', reason);
set({
isConnected: false,
currentRoom: null,
@@ -151,6 +188,30 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
});
});
room.on(RoomEvent.Reconnecting, () => {
console.warn('[voice] reconnecting to LiveKit...');
set({ error: 'Reconnecting...' });
});
room.on(RoomEvent.Reconnected, () => {
console.log('[voice] reconnected to LiveKit');
set({ error: null });
syncParticipants();
});
room.on(RoomEvent.SignalConnected, () => {
console.log('[voice] signal channel connected');
});
room.on(RoomEvent.ConnectionQualityChanged, (_quality: any, participant: Participant) => {
if (participant.identity === room.localParticipant.identity) {
// if quality drops to poor, log it
if (_quality === 'poor') {
console.warn('[voice] poor connection quality');
}
}
});
room.on(RoomEvent.ParticipantConnected, () => {
syncParticipants();
});
@@ -215,6 +276,16 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
console.warn("[voice] failed to set initial mute state:", micErr);
}
// Restore saved device preferences
const prefs = loadDevicePrefs();
try {
if (prefs.audioInput) await room.switchActiveDevice('audioinput', prefs.audioInput);
if (prefs.audioOutput) await room.switchActiveDevice('audiooutput', prefs.audioOutput);
if (prefs.videoInput) await room.switchActiveDevice('videoinput', prefs.videoInput);
} catch (devErr) {
console.warn("[voice] failed to restore saved devices:", devErr);
}
set({ isJoining: false });
// Notify WebSocket peers
+42
View File
@@ -0,0 +1,42 @@
import { create } from 'zustand';
export interface VoicePresenceEntry {
userId: string;
username: string;
channelId: string;
}
interface VoicePresenceState {
// channelId -> userId -> entry
presence: Record<string, Record<string, VoicePresenceEntry>>;
_handleJoin: (entry: VoicePresenceEntry) => void;
_handleLeave: (userId: string, channelId: string) => void;
getParticipants: (channelId: string) => VoicePresenceEntry[];
}
export const useVoicePresenceStore = create<VoicePresenceState>((set, get) => ({
presence: {},
_handleJoin: (entry) => {
set((state) => {
const room = { ...(state.presence[entry.channelId] || {}) };
room[entry.userId] = entry;
return { presence: { ...state.presence, [entry.channelId]: room } };
});
},
_handleLeave: (userId, channelId) => {
set((state) => {
const room = { ...(state.presence[channelId] || {}) };
delete room[userId];
const next = { ...state.presence, [channelId]: room };
if (Object.keys(room).length === 0) delete next[channelId];
return { presence: next };
});
},
getParticipants: (channelId) => {
const room = get().presence[channelId];
return room ? Object.values(room) : [];
},
}));
+19
View File
@@ -5,6 +5,7 @@ import { useServerStore } from './server.ts';
import { usePresenceStore } from './presence.ts';
import { useTypingStore } from './typing.ts';
import { useVoiceStore } from './voice.ts';
import { useVoicePresenceStore } from './voicePresence.ts';
import { useAuthStore } from './auth.ts';
import { useConversationStore } from './conversation.ts';
import { useReadStatesStore } from './readStates.ts';
@@ -274,6 +275,24 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
});
break;
}
case 'VOICE_JOIN': {
const { room_name, user_id, username } = payload as Record<string, string>;
if (room_name && user_id && username) {
useVoicePresenceStore.getState()._handleJoin({
userId: user_id,
username,
channelId: room_name,
});
}
break;
}
case 'VOICE_LEAVE': {
const { room_name, user_id } = payload as Record<string, string>;
if (room_name && user_id) {
useVoicePresenceStore.getState()._handleLeave(user_id, room_name);
}
break;
}
default:
break;
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AudioRenderers.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ConnectionStatus.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DeviceSettingsModal.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ExpandableImage.tsx","./src/components/FeatureRequestsPanel.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/FormatToolbar.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallBanner.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageInput.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/NotificationPrompt.tsx","./src/components/PinnedMessages.tsx","./src/components/Poll.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/slashCommands.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/featureRequest.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AudioRenderers.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ConnectionStatus.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DeviceSettingsModal.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ExpandableImage.tsx","./src/components/FeatureRequestsPanel.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/FormatToolbar.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallBanner.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageInput.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/NotificationPrompt.tsx","./src/components/PinnedMessages.tsx","./src/components/Poll.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/slashCommands.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/featureRequest.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/voicePresence.ts","./src/stores/ws.ts"],"version":"5.9.3"}