1 Commits

Author SHA1 Message Date
hobokenchicken fa8cb1b0ca fix: windows build — use pwsh instead of bash for Rust install
Release Desktop Apps / build-windows (push) Failing after 3h0m27s
Release Desktop Apps / build-linux (push) Has been cancelled
Release Desktop Apps / release (push) Has been cancelled
2026-07-07 19:17:36 +00:00
18 changed files with 80 additions and 350 deletions
+13 -14
View File
@@ -6,7 +6,7 @@ on:
jobs: jobs:
build-linux: build-linux:
runs-on: ubuntu-latest runs-on: linux
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@@ -37,17 +37,18 @@ jobs:
with: { node-version: 20 } with: { node-version: 20 }
- name: Install Rust - name: Install Rust
run: | run: |
powershell -ExecutionPolicy Bypass -Command "Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe" if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) {
rustup-init.exe -y --default-toolchain stable Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe
set PATH=%USERPROFILE%\.cargo\bin;%PATH% .\rustup-init.exe -y --default-toolchain stable
$env:Path += ";$env:USERPROFILE\.cargo\bin"
}
rustc --version rustc --version
shell: cmd shell: pwsh
- name: Build frontend - name: Build frontend
run: cd web && npm ci && npm run build run: cd web; npm ci; npm run build
shell: cmd
- name: Build Tauri bundles - name: Build Tauri bundles
run: cd web && set PATH=%USERPROFILE%\.cargo\bin;%PATH% && npx tauri build run: cd web; $env:Path += ";$env:USERPROFILE\.cargo\bin"; npx tauri build
shell: cmd shell: pwsh
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
@@ -58,15 +59,13 @@ jobs:
release: release:
needs: [build-linux, build-windows] needs: [build-linux, build-windows]
runs-on: ubuntu-latest runs-on: linux
steps: steps:
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
- name: Create release - name: Create release
uses: actions/gitea-release-action@v3 uses: softprops/action-gh-release@v2
env:
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
with: with:
tag_name: ${{ github.ref_name }}
files: | files: |
linux-bundles/* linux-bundles/*
windows-bundles/* windows-bundles/*
generate_release_notes: true
-7
View File
@@ -243,13 +243,6 @@ func main() {
notification.NewHandler(database.DB, logger).RegisterRoutes(r) 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 // Push notifications
pushHandler.RegisterRoutes(r) 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"` 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.Get("/{channelID}/events", h.ListEvents)
r.Post("/{channelID}/events", h.CreateEvent) r.Post("/{channelID}/events", h.CreateEvent)
r.Patch("/events/{eventID}", h.UpdateEvent) 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.Get("/{channelID}/threads", h.ListThreads)
r.Patch("/threads/{threadID}", h.UpdateThread) r.Patch("/threads/{threadID}", h.UpdateThread)
h.registerForumRoutes(r) h.registerForumRoutes(r)
h.RegisterCalendarRoutes(r) h.registerCalendarRoutes(r)
h.registerDocRoutes(r) h.registerDocRoutes(r)
h.registerListRoutes(r) h.registerListRoutes(r)
h.registerOverrideRoutes(r) h.registerOverrideRoutes(r)
-12
View File
@@ -155,18 +155,6 @@ func (c *Client) readPump() {
c.Hub.BroadcastEvent(Event{Type: event.Type, Data: data}) c.Hub.BroadcastEvent(Event{Type: event.Type, Data: data})
case EventPresenceUpdate: case EventPresenceUpdate:
c.Hub.BroadcastEvent(event) 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: case EventVoiceWhisper:
// Forward voice whispers only to the target user, not broadcast // Forward voice whispers only to the target user, not broadcast
var whisperData struct { var whisperData struct {
+1 -1
View File
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]] [[package]]
name = "app" name = "app"
version = "0.2.0" version = "0.1.0"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
+29 -56
View File
@@ -1,26 +1,8 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef } from 'react';
import { useVoiceStore } from '../stores/voice.ts'; import { useVoiceStore } from '../stores/voice.ts';
import { Track, RoomEvent } from 'livekit-client'; import { Track } from 'livekit-client';
import type { Participant, TrackPublication, Room } 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 }) { function RemoteAudioTrack({ participant, room }: { participant: Participant; room: Room }) {
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
@@ -30,35 +12,44 @@ function RemoteAudioTrack({ participant, room }: { participant: Participant; roo
let pub: TrackPublication | undefined; let pub: TrackPublication | undefined;
const tryPlay = () => { const attachTrack = () => {
el.play().catch(() => {
pendingAudio.add(el);
ensureInteractionListener();
});
};
const attachIfReady = () => {
pub = participant.getTrackPublication(Track.Source.Microphone); pub = participant.getTrackPublication(Track.Source.Microphone);
if (pub?.track && el) { if (pub?.track && el) {
pub.track.attach(el); pub.track.attach(el);
tryPlay(); el.play().catch(() => {});
} }
}; };
attachIfReady(); const detachTrack = () => {
if (pub?.track && el) {
pub.track.detach(el);
}
};
const handleSubscribed = (track: Track, _pub: TrackPublication, p: Participant) => { attachTrack();
if (p.identity === participant.identity && _pub.source === Track.Source.Microphone) {
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) {
track.attach(el); track.attach(el);
tryPlay(); el.play().catch(() => {});
} }
}; };
room.on(RoomEvent.TrackSubscribed, handleSubscribed); room.on('trackSubscribed' as any, handleSubscribed);
return () => { return () => {
if (pub?.track) pub.track.detach(el); detachTrack();
pendingAudio.delete(el); room.off('trackPublished' as any, handlePublished);
room.off(RoomEvent.TrackSubscribed, handleSubscribed); room.off('trackSubscribed' as any, handleSubscribed);
}; };
}, [participant, room]); }, [participant, room]);
@@ -67,25 +58,7 @@ function RemoteAudioTrack({ participant, room }: { participant: Participant; roo
export function AudioRenderers() { export function AudioRenderers() {
const room = useVoiceStore((s) => s._room); 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; if (!room) return null;
const remoteParticipants = Array.from(room.remoteParticipants.values()); const remoteParticipants = Array.from(room.remoteParticipants.values());
+21 -89
View File
@@ -33,7 +33,6 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
const [end, setEnd] = useState(''); const [end, setEnd] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null); const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
const [view, setView] = useState<'grid' | 'list'>('list');
const monthStart = useMemo(() => new Date(date.getFullYear(), date.getMonth(), 1), [date]); const monthStart = useMemo(() => new Date(date.getFullYear(), date.getMonth(), 1), [date]);
const monthEnd = useMemo(() => new Date(date.getFullYear(), date.getMonth() + 1, 0), [date]); const monthEnd = useMemo(() => new Date(date.getFullYear(), date.getMonth() + 1, 0), [date]);
@@ -50,11 +49,7 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
const to = `${date.getFullYear()}-${String(date.getMonth() + 2).padStart(2, '0')}-01T00:00:00Z`; const to = `${date.getFullYear()}-${String(date.getMonth() + 2).padStart(2, '0')}-01T00:00:00Z`;
setLoading(true); setLoading(true);
api.get<CalendarEvent[]>(`/channels/${channelId}/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`) api.get<CalendarEvent[]>(`/channels/${channelId}/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
.then((data) => { .then((data) => setEvents(Array.isArray(data) ? 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)); .finally(() => setLoading(false));
}, [channelId, date]); }, [channelId, date]);
@@ -66,29 +61,6 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
}); });
}; };
const weeks = useMemo(() => {
const sorted = [...events].sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
const grouped: { label: string; events: CalendarEvent[] }[] = [];
let currentKey = '';
for (const ev of sorted) {
const d = new Date(ev.start_time);
// week key = Monday of that week
const dayOfWeek = d.getDay();
const monday = new Date(d);
monday.setDate(d.getDate() - ((dayOfWeek + 6) % 7));
const key = monday.toISOString().slice(0, 10);
if (key !== currentKey) {
const sun = new Date(monday);
sun.setDate(monday.getDate() + 6);
const label = `${monday.toLocaleDateString('default', { month: 'short', day: 'numeric' })} \u2013 ${sun.toLocaleDateString('default', { month: 'short', day: 'numeric' })}`;
grouped.push({ label, events: [] });
currentKey = key;
}
grouped[grouped.length - 1].events.push(ev);
}
return grouped;
}, [events]);
const handleCreate = async () => { const handleCreate = async () => {
if (!title || !start) return; if (!title || !start) return;
const payload = { title, start_time: start, end_time: end || undefined, description: description || undefined }; const payload = { title, start_time: start, end_time: end || undefined, description: description || undefined };
@@ -113,11 +85,7 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
<div className="flex flex-col h-full bg-gb-bg"> <div className="flex flex-col h-full bg-gb-bg">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between"> <div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between">
<span> {channelName}</span> <span> {channelName}</span>
<div className="flex gap-2"> <button onClick={() => setShowForm((p) => !p)} className="text-xs text-gb-fg-f hover:text-gb-orange font-mono">[NEW EVENT]</button>
<button onClick={() => setView('list')} className={`text-xs font-mono ${view === 'list' ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}>[LIST]</button>
<button onClick={() => setView('grid')} className={`text-xs font-mono ${view === 'grid' ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}>[GRID]</button>
<button onClick={() => setShowForm((p) => !p)} className="text-xs text-gb-fg-f hover:text-gb-orange font-mono">[NEW EVENT]</button>
</div>
</div> </div>
{showForm && ( {showForm && (
<div className="p-3 border-b border-gb-bg-t space-y-2 font-mono text-xs"> <div className="p-3 border-b border-gb-bg-t space-y-2 font-mono text-xs">
@@ -135,66 +103,30 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
</div> </div>
{loading && <div className="p-3 text-gb-fg-f text-xs font-mono">[loading...]</div>} {loading && <div className="p-3 text-gb-fg-f text-xs font-mono">[loading...]</div>}
<div className="flex-1 overflow-y-auto p-3"> <div className="flex-1 overflow-y-auto p-3">
{view === 'grid' ? ( <div className="grid grid-cols-7 gap-1 text-center text-xs font-mono text-gb-fg-s mb-1">
<> {['S','M','T','W','T','F','S'].map((d) => <div key={d}>{d}</div>)}
<div className="grid grid-cols-7 gap-1 text-center text-xs font-mono text-gb-fg-s mb-1"> </div>
{['S','M','T','W','T','F','S'].map((d) => <div key={d}>{d}</div>)} <div className="grid grid-cols-7 gap-1">
</div> {days.map((day, idx) => (
<div className="grid grid-cols-7 gap-1"> <div key={idx} className="min-h-16 border border-gb-bg-t p-1 text-xs">
{days.map((day, idx) => ( {day !== null && (
<div key={idx} className="min-h-16 border border-gb-bg-t p-1 text-xs"> <>
{day !== null && ( <div className="text-gb-fg-f font-mono">{day}</div>
<> {eventsForDay(day).map((ev) => (
<div className="text-gb-fg-f font-mono">{day}</div>
{eventsForDay(day).map((ev) => (
<button
key={ev.id}
onClick={() => setSelectedEvent(ev)}
className="w-full text-left mt-1 px-1 py-0.5 truncate text-gb-bg font-mono"
style={{ backgroundColor: ev.color || '#b8bb26' }}
>
{ev.title}
</button>
))}
</>
)}
</div>
))}
</div>
</>
) : (
<div className="space-y-4 font-mono text-xs">
{weeks.length === 0 && !loading && (
<div className="text-gb-fg-f">No events this month.</div>
)}
{weeks.map((week) => (
<div key={week.label}>
<div className="text-gb-orange font-bold mb-1 border-b border-gb-bg-t pb-1">
{week.label}
</div>
{week.events.map((ev) => {
const s = new Date(ev.start_time);
const e = ev.end_time ? new Date(ev.end_time) : null;
const dayName = s.toLocaleDateString('default', { weekday: 'short' });
const dateStr = s.toLocaleDateString('default', { month: 'short', day: 'numeric' });
const startStr = s.toLocaleTimeString('default', { hour: 'numeric', minute: '2-digit' });
const endStr = e ? e.toLocaleTimeString('default', { hour: 'numeric', minute: '2-digit' }) : '';
return (
<button <button
key={ev.id} key={ev.id}
onClick={() => setSelectedEvent(ev)} onClick={() => setSelectedEvent(ev)}
className="w-full text-left px-2 py-1 hover:bg-gb-bg-t rounded-sm flex items-center gap-3" className="w-full text-left mt-1 px-1 py-0.5 truncate text-gb-bg font-mono"
style={{ backgroundColor: ev.color || '#b8bb26' }}
> >
<span className="text-gb-fg-f w-20 shrink-0">{dayName} {dateStr}</span> {ev.title}
<span className="text-gb-fg-s w-28 shrink-0">{startStr}{endStr ? ` \u2013 ${endStr}` : ''}</span>
<span className="text-gb-fg truncate">{ev.title}</span>
</button> </button>
); ))}
})} </>
</div> )}
))} </div>
</div> ))}
)} </div>
</div> </div>
{selectedEvent && ( {selectedEvent && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gb-bg/90 p-4" onClick={() => setSelectedEvent(null)}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-gb-bg/90 p-4" onClick={() => setSelectedEvent(null)}>
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useVoiceStore } from '../stores/voice.ts'; import { useVoiceStore } from '../stores/voice.ts';
import { saveDevicePref } from '../stores/voice.ts';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
@@ -49,7 +48,6 @@ export function DeviceSettingsModal({ onClose }: Props) {
if (!room) return; if (!room) return;
try { try {
await room.switchActiveDevice(kind, deviceId); await room.switchActiveDevice(kind, deviceId);
saveDevicePref(kind, deviceId);
if (kind === 'audioinput') setActiveAudioInput(deviceId); if (kind === 'audioinput') setActiveAudioInput(deviceId);
if (kind === 'audiooutput') setActiveAudioOutput(deviceId); if (kind === 'audiooutput') setActiveAudioOutput(deviceId);
if (kind === 'videoinput') setActiveVideoInput(deviceId); if (kind === 'videoinput') setActiveVideoInput(deviceId);
+2 -16
View File
@@ -546,12 +546,7 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
placeholder={isDragOver ? "Drop file here..." : placeholder} placeholder={isDragOver ? "Drop file here..." : placeholder}
rows={1} rows={1}
disabled={disabled} disabled={disabled}
className={`w-full bg-transparent outline-none border-none resize-none overflow-hidden text-[14px] leading-snug py-1 font-mono ${isDragOver ? "bg-gb-bg-t" : ""}`} className={`w-full bg-transparent outline-none border-none resize-none overflow-y-auto max-h-32 text-[14px] leading-snug py-1 font-mono ${isDragOver ? "bg-gb-bg-t" : ""}`}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}}
/> />
) : ( ) : (
<div <div
@@ -566,7 +561,7 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
} }
}} }}
data-placeholder={isDragOver ? "Drop file here..." : placeholder} data-placeholder={isDragOver ? "Drop file here..." : placeholder}
className={`w-full outline-none resize-none overflow-hidden text-[14px] leading-snug py-1 font-mono className={`w-full outline-none resize-none overflow-y-auto max-h-32 text-[14px] leading-snug py-1 font-mono
before:content-[attr(data-placeholder)] before:text-gb-fg-f before:opacity-60 before:content-[attr(data-placeholder)] before:text-gb-fg-f before:opacity-60
empty:before:block before:hidden empty:before:block before:hidden
${isDragOver ? "bg-gb-bg-t" : ""} ${isDragOver ? "bg-gb-bg-t" : ""}
@@ -690,15 +685,6 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
<span className="flex-1" /> <span className="flex-1" />
{/* character counter */}
{value.length > 0 && (
<span className={`text-xs font-mono tabular-nums px-1 ${
value.length > 3800 ? "text-gb-orange" : "text-gb-fg-f"
}`}>
{4000 - value.length}/4000
</span>
)}
{/* mode toggle */} {/* mode toggle */}
<button type="button" disabled={disabled} onClick={toggleMode} <button type="button" disabled={disabled} onClick={toggleMode}
title={mode === "md" ? "Switch to rich text" : "Switch to markdown"} title={mode === "md" ? "Switch to rich text" : "Switch to markdown"}
+10 -17
View File
@@ -2,7 +2,6 @@ import { useVoiceStore } from '../stores/voice.ts';
import { useAuthStore } from '../stores/auth.ts'; import { useAuthStore } from '../stores/auth.ts';
import { usePermissions } from '../lib/usePermissions.ts'; import { usePermissions } from '../lib/usePermissions.ts';
import { useChannelStore } from '../stores/channel.ts'; import { useChannelStore } from '../stores/channel.ts';
import { useVoicePresenceStore } from '../stores/voicePresence.ts';
import { api } from '../lib/api.ts'; import { api } from '../lib/api.ts';
interface VoiceChannelProps { interface VoiceChannelProps {
@@ -14,8 +13,7 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
const currentRoom = useVoiceStore((state) => state.currentRoom); const currentRoom = useVoiceStore((state) => state.currentRoom);
const isConnected = useVoiceStore((state) => state.isConnected); const isConnected = useVoiceStore((state) => state.isConnected);
const joinVoice = useVoiceStore((state) => state.joinVoice); const joinVoice = useVoiceStore((state) => state.joinVoice);
const liveParticipants = useVoiceStore((state) => state.participants); const participants = useVoiceStore((state) => state.participants);
const presenceParticipants = useVoicePresenceStore((s) => s.getParticipants(channelId));
const currentUserId = useAuthStore((state) => state.user?.id); const currentUserId = useAuthStore((state) => state.user?.id);
const activeChannelId = useChannelStore((s) => s.activeChannelId); const activeChannelId = useChannelStore((s) => s.activeChannelId);
@@ -42,11 +40,6 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
const isActive = currentRoom === channelId; 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 = () => { const handleClick = () => {
if (!isActive) { if (!isActive) {
console.log("[VoiceChannel] joining voice:", channelId, channelName); console.log("[VoiceChannel] joining voice:", channelId, channelName);
@@ -76,19 +69,19 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
🔊 🔊
</span> </span>
<span className="truncate">{channelName}</span> <span className="truncate">{channelName}</span>
{displayParticipants.length > 0 && ( {isActive && participants.length > 0 && (
<span className="ml-auto text-xxs text-gb-fg-f"> <span className="ml-auto text-xxs text-gb-fg-f">
[{displayParticipants.length}] [{participants.length}]
</span> </span>
)} )}
</button> </button>
{displayParticipants.length > 0 && ( {isActive && (
<div className="pl-7 py-0.5"> <div className="pl-7 py-0.5">
{displayParticipants.map((p) => { {participants.map((p) => {
const isMe = p.userId === currentUserId; const isMe = p.identity === currentUserId;
return ( return (
<div <div
key={p.userId} key={p.identity}
className="text-xxs text-gb-fg-f flex items-center gap-1 group" className="text-xxs text-gb-fg-f flex items-center gap-1 group"
> >
<span className={p.isSpeaking ? 'text-gb-green' : 'text-gb-fg-f'}> <span className={p.isSpeaking ? 'text-gb-green' : 'text-gb-fg-f'}>
@@ -100,12 +93,12 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
{p.isScreenSharing && <span className="text-gb-aqua" title="Sharing screen">🖥</span>} {p.isScreenSharing && <span className="text-gb-aqua" title="Sharing screen">🖥</span>}
{p.hasVideo && <span className="text-gb-orange" title="Camera on">🎥</span>} {p.hasVideo && <span className="text-gb-orange" title="Camera on">🎥</span>}
{!isMe && isActive && ( {!isMe && (
<div className="ml-auto opacity-0 group-hover:opacity-100 flex items-center gap-1"> <div className="ml-auto opacity-0 group-hover:opacity-100 flex items-center gap-1">
{canMute && !p.isMuted && ( {canMute && !p.isMuted && (
<button <button
className="text-gb-fg-f hover:text-gb-red" className="text-gb-fg-f hover:text-gb-red"
onClick={(e) => { e.stopPropagation(); handleMute(p.userId); }} onClick={(e) => { e.stopPropagation(); handleMute(p.identity); }}
title={`Mute ${p.username}`} title={`Mute ${p.username}`}
> >
[mute] [mute]
@@ -113,7 +106,7 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
)} )}
<button <button
className="text-gb-fg-f hover:text-gb-orange" className="text-gb-fg-f hover:text-gb-orange"
onClick={(e) => { e.stopPropagation(); whisperTo(p.userId, p.username); }} onClick={(e) => { e.stopPropagation(); whisperTo(p.identity, p.username); }}
title={`Whisper to ${p.username}`} title={`Whisper to ${p.username}`}
> >
[whisper] [whisper]
+1 -72
View File
@@ -64,42 +64,6 @@ interface VoiceState {
dismissWhisper: (timestamp: number) => void; 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 { function participantToVoice(p: Participant): VoiceParticipant {
const micPub = p.getTrackPublication(Track.Source.Microphone); const micPub = p.getTrackPublication(Track.Source.Microphone);
const camPub = p.getTrackPublication(Track.Source.Camera); const camPub = p.getTrackPublication(Track.Source.Camera);
@@ -174,8 +138,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
syncParticipants(); syncParticipants();
}); });
room.on(RoomEvent.Disconnected, (reason?: any) => { room.on(RoomEvent.Disconnected, () => {
console.warn('[voice] disconnected, reason:', reason);
set({ set({
isConnected: false, isConnected: false,
currentRoom: null, currentRoom: null,
@@ -188,30 +151,6 @@ 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, () => { room.on(RoomEvent.ParticipantConnected, () => {
syncParticipants(); syncParticipants();
}); });
@@ -276,16 +215,6 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
console.warn("[voice] failed to set initial mute state:", micErr); 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 }); set({ isJoining: false });
// Notify WebSocket peers // Notify WebSocket peers
-42
View File
@@ -1,42 +0,0 @@
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,7 +5,6 @@ import { useServerStore } from './server.ts';
import { usePresenceStore } from './presence.ts'; import { usePresenceStore } from './presence.ts';
import { useTypingStore } from './typing.ts'; import { useTypingStore } from './typing.ts';
import { useVoiceStore } from './voice.ts'; import { useVoiceStore } from './voice.ts';
import { useVoicePresenceStore } from './voicePresence.ts';
import { useAuthStore } from './auth.ts'; import { useAuthStore } from './auth.ts';
import { useConversationStore } from './conversation.ts'; import { useConversationStore } from './conversation.ts';
import { useReadStatesStore } from './readStates.ts'; import { useReadStatesStore } from './readStates.ts';
@@ -275,24 +274,6 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
}); });
break; 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: default:
break; 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/voicePresence.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/ws.ts"],"version":"5.9.3"}