Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87d7345155 | |||
| a7646481a4 | |||
| 5d37fb899d | |||
| af913b9923 | |||
| 9a6f15662b | |||
| 7d1bd02e31 | |||
| 8542243745 | |||
| 90bddc65d4 | |||
| 67a5a54244 | |||
| 33c9dc4f15 | |||
| e6dfe43926 | |||
| 62e8354d03 | |||
| 1139e90fc3 | |||
| 60edc0b5b4 | |||
| 836bcd8b8d | |||
| 8fd649356c | |||
| 4a02d054cc |
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: linux
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -35,11 +35,19 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20 }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install Rust
|
||||
run: |
|
||||
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
|
||||
- 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
|
||||
run: cd web; npx tauri build
|
||||
run: cd web && set PATH=%USERPROFILE%\.cargo\bin;%PATH% && npx tauri build
|
||||
shell: cmd
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -50,13 +58,15 @@ jobs:
|
||||
|
||||
release:
|
||||
needs: [build-linux, build-windows]
|
||||
runs-on: linux
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: actions/gitea-release-action@v3
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: |
|
||||
linux-bundles/*
|
||||
windows-bundles/*
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -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.
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+1
-1
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
|
||||
@@ -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,6 +67,24 @@ 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;
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
||||
const [end, setEnd] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
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 monthEnd = useMemo(() => new Date(date.getFullYear(), date.getMonth() + 1, 0), [date]);
|
||||
@@ -49,7 +50,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]);
|
||||
|
||||
@@ -61,6 +66,29 @@ 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 () => {
|
||||
if (!title || !start) return;
|
||||
const payload = { title, start_time: start, end_time: end || undefined, description: description || undefined };
|
||||
@@ -85,8 +113,12 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
||||
<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">
|
||||
<span>○ {channelName}</span>
|
||||
<div className="flex gap-2">
|
||||
<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>
|
||||
{showForm && (
|
||||
<div className="p-3 border-b border-gb-bg-t space-y-2 font-mono text-xs">
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="title" className="terminal-input w-full" />
|
||||
@@ -103,6 +135,8 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
||||
</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">
|
||||
{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>
|
||||
@@ -127,6 +161,40 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
||||
</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
|
||||
key={ev.id}
|
||||
onClick={() => setSelectedEvent(ev)}
|
||||
className="w-full text-left px-2 py-1 hover:bg-gb-bg-t rounded-sm flex items-center gap-3"
|
||||
>
|
||||
<span className="text-gb-fg-f w-20 shrink-0">{dayName} {dateStr}</span>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedEvent && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gb-bg/90 p-4" onClick={() => setSelectedEvent(null)}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -546,7 +546,12 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
||||
placeholder={isDragOver ? "Drop file here..." : placeholder}
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
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" : ""}`}
|
||||
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" : ""}`}
|
||||
onInput={(e) => {
|
||||
const el = e.currentTarget;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = el.scrollHeight + 'px';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@@ -561,7 +566,7 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
||||
}
|
||||
}}
|
||||
data-placeholder={isDragOver ? "Drop file here..." : placeholder}
|
||||
className={`w-full outline-none resize-none overflow-y-auto max-h-32 text-[14px] leading-snug py-1 font-mono
|
||||
className={`w-full outline-none resize-none overflow-hidden text-[14px] leading-snug py-1 font-mono
|
||||
before:content-[attr(data-placeholder)] before:text-gb-fg-f before:opacity-60
|
||||
empty:before:block before:hidden
|
||||
${isDragOver ? "bg-gb-bg-t" : ""}
|
||||
@@ -685,6 +690,15 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
||||
|
||||
<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 */}
|
||||
<button type="button" disabled={disabled} onClick={toggleMode}
|
||||
title={mode === "md" ? "Switch to rich text" : "Switch to markdown"}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) : [];
|
||||
},
|
||||
}));
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user