Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87d7345155 | |||
| a7646481a4 | |||
| 5d37fb899d | |||
| af913b9923 | |||
| 9a6f15662b | |||
| 7d1bd02e31 | |||
| 8542243745 | |||
| 90bddc65d4 | |||
| 67a5a54244 | |||
| 33c9dc4f15 | |||
| e6dfe43926 | |||
| 62e8354d03 | |||
| 1139e90fc3 | |||
| 60edc0b5b4 |
@@ -37,18 +37,17 @@ jobs:
|
|||||||
with: { node-version: 20 }
|
with: { node-version: 20 }
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
run: |
|
run: |
|
||||||
if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) {
|
powershell -ExecutionPolicy Bypass -Command "Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe"
|
||||||
Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe
|
rustup-init.exe -y --default-toolchain stable
|
||||||
.\rustup-init.exe -y --default-toolchain stable
|
set PATH=%USERPROFILE%\.cargo\bin;%PATH%
|
||||||
$env:Path += ";$env:USERPROFILE\.cargo\bin"
|
|
||||||
}
|
|
||||||
rustc --version
|
rustc --version
|
||||||
shell: powershell
|
shell: cmd
|
||||||
- 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; $env:Path += ";$env:USERPROFILE\.cargo\bin"; npx tauri build
|
run: cd web && set PATH=%USERPROFILE%\.cargo\bin;%PATH% && npx tauri build
|
||||||
shell: powershell
|
shell: cmd
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -243,6 +243,13 @@ 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.
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -155,6 +155,18 @@ 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 {
|
||||||
|
|||||||
Generated
+1
-1
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useVoiceStore } from '../stores/voice.ts';
|
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';
|
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);
|
||||||
|
|
||||||
@@ -12,44 +30,35 @@ function RemoteAudioTrack({ participant, room }: { participant: Participant; roo
|
|||||||
|
|
||||||
let pub: TrackPublication | undefined;
|
let pub: TrackPublication | undefined;
|
||||||
|
|
||||||
const attachTrack = () => {
|
const tryPlay = () => {
|
||||||
|
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);
|
||||||
el.play().catch(() => {});
|
tryPlay();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const detachTrack = () => {
|
attachIfReady();
|
||||||
if (pub?.track && el) {
|
|
||||||
pub.track.detach(el);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
attachTrack();
|
const handleSubscribed = (track: Track, _pub: TrackPublication, p: Participant) => {
|
||||||
|
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);
|
||||||
el.play().catch(() => {});
|
tryPlay();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
room.on('trackSubscribed' as any, handleSubscribed);
|
room.on(RoomEvent.TrackSubscribed, handleSubscribed);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
detachTrack();
|
if (pub?.track) pub.track.detach(el);
|
||||||
room.off('trackPublished' as any, handlePublished);
|
pendingAudio.delete(el);
|
||||||
room.off('trackSubscribed' as any, handleSubscribed);
|
room.off(RoomEvent.TrackSubscribed, handleSubscribed);
|
||||||
};
|
};
|
||||||
}, [participant, room]);
|
}, [participant, room]);
|
||||||
|
|
||||||
@@ -58,6 +67,24 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ 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]);
|
||||||
@@ -49,7 +50,11 @@ 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) => 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));
|
.finally(() => setLoading(false));
|
||||||
}, [channelId, date]);
|
}, [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 () => {
|
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 };
|
||||||
@@ -85,7 +113,11 @@ 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>
|
||||||
<button onClick={() => setShowForm((p) => !p)} className="text-xs text-gb-fg-f hover:text-gb-orange font-mono">[NEW EVENT]</button>
|
<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>
|
</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">
|
||||||
@@ -103,30 +135,66 @@ 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">
|
||||||
<div className="grid grid-cols-7 gap-1 text-center text-xs font-mono text-gb-fg-s mb-1">
|
{view === 'grid' ? (
|
||||||
{['S','M','T','W','T','F','S'].map((d) => <div key={d}>{d}</div>)}
|
<>
|
||||||
</div>
|
<div className="grid grid-cols-7 gap-1 text-center text-xs font-mono text-gb-fg-s mb-1">
|
||||||
<div className="grid grid-cols-7 gap-1">
|
{['S','M','T','W','T','F','S'].map((d) => <div key={d}>{d}</div>)}
|
||||||
{days.map((day, idx) => (
|
</div>
|
||||||
<div key={idx} className="min-h-16 border border-gb-bg-t p-1 text-xs">
|
<div className="grid grid-cols-7 gap-1">
|
||||||
{day !== null && (
|
{days.map((day, idx) => (
|
||||||
<>
|
<div key={idx} className="min-h-16 border border-gb-bg-t p-1 text-xs">
|
||||||
<div className="text-gb-fg-f font-mono">{day}</div>
|
{day !== null && (
|
||||||
{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 mt-1 px-1 py-0.5 truncate text-gb-bg font-mono"
|
className="w-full text-left px-2 py-1 hover:bg-gb-bg-t rounded-sm flex items-center gap-3"
|
||||||
style={{ backgroundColor: ev.color || '#b8bb26' }}
|
|
||||||
>
|
>
|
||||||
{ev.title}
|
<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>
|
</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,5 +1,6 @@
|
|||||||
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;
|
||||||
@@ -48,6 +49,7 @@ 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);
|
||||||
|
|||||||
@@ -546,7 +546,12 @@ 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-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
|
<div
|
||||||
@@ -561,7 +566,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-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
|
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" : ""}
|
||||||
@@ -685,6 +690,15 @@ 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"}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 {
|
||||||
@@ -13,7 +14,8 @@ 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 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 currentUserId = useAuthStore((state) => state.user?.id);
|
||||||
const activeChannelId = useChannelStore((s) => s.activeChannelId);
|
const activeChannelId = useChannelStore((s) => s.activeChannelId);
|
||||||
@@ -40,6 +42,11 @@ 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);
|
||||||
@@ -69,19 +76,19 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
|
|||||||
🔊
|
🔊
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{channelName}</span>
|
<span className="truncate">{channelName}</span>
|
||||||
{isActive && participants.length > 0 && (
|
{displayParticipants.length > 0 && (
|
||||||
<span className="ml-auto text-xxs text-gb-fg-f">
|
<span className="ml-auto text-xxs text-gb-fg-f">
|
||||||
[{participants.length}]
|
[{displayParticipants.length}]
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isActive && (
|
{displayParticipants.length > 0 && (
|
||||||
<div className="pl-7 py-0.5">
|
<div className="pl-7 py-0.5">
|
||||||
{participants.map((p) => {
|
{displayParticipants.map((p) => {
|
||||||
const isMe = p.identity === currentUserId;
|
const isMe = p.userId === currentUserId;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.identity}
|
key={p.userId}
|
||||||
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'}>
|
||||||
@@ -93,12 +100,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 && (
|
{!isMe && isActive && (
|
||||||
<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.identity); }}
|
onClick={(e) => { e.stopPropagation(); handleMute(p.userId); }}
|
||||||
title={`Mute ${p.username}`}
|
title={`Mute ${p.username}`}
|
||||||
>
|
>
|
||||||
[mute]
|
[mute]
|
||||||
@@ -106,7 +113,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.identity, p.username); }}
|
onClick={(e) => { e.stopPropagation(); whisperTo(p.userId, p.username); }}
|
||||||
title={`Whisper to ${p.username}`}
|
title={`Whisper to ${p.username}`}
|
||||||
>
|
>
|
||||||
[whisper]
|
[whisper]
|
||||||
|
|||||||
+72
-1
@@ -64,6 +64,42 @@ 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);
|
||||||
@@ -138,7 +174,8 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
syncParticipants();
|
syncParticipants();
|
||||||
});
|
});
|
||||||
|
|
||||||
room.on(RoomEvent.Disconnected, () => {
|
room.on(RoomEvent.Disconnected, (reason?: any) => {
|
||||||
|
console.warn('[voice] disconnected, reason:', reason);
|
||||||
set({
|
set({
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
currentRoom: null,
|
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, () => {
|
room.on(RoomEvent.ParticipantConnected, () => {
|
||||||
syncParticipants();
|
syncParticipants();
|
||||||
});
|
});
|
||||||
@@ -215,6 +276,16 @@ 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
|
||||||
|
|||||||
@@ -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 { 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';
|
||||||
@@ -274,6 +275,24 @@ 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 @@
|
|||||||
{"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