This commit is contained in:
Arjun Patel
2026-06-01 12:46:14 -07:00
parent 2fe562ce2b
commit c6354ab0d3
29 changed files with 491 additions and 222 deletions
+3 -1
View File
@@ -5,5 +5,7 @@ import '@/styles/globals.css';
initSentryRenderer();
const root = createRoot(document.getElementById('root')!);
const container = document.getElementById('root');
if (!container) throw new Error('Root element #root not found');
const root = createRoot(container);
root.render(<AutoplayApp />);
@@ -62,6 +62,8 @@ export function AttachmentLightbox({
: null;
const isOpen = current !== null;
const hasMultiple = items.length > 1;
// 1-based position for the "x / N" counter; null when nothing is open.
const position = openIndex !== null ? openIndex + 1 : null;
// Remote items resolve through the signed-URL cache; disabled when not remote.
const remoteObjectId =
@@ -107,7 +109,7 @@ export function AttachmentLightbox({
if (wasLast) {
onOpenChange(null);
} else if (wasAtEnd) {
onOpenChange(openIndex! - 1);
onOpenChange(items.length - 2);
}
// Otherwise openIndex stays — the next item shifts into its place.
};
@@ -187,7 +189,7 @@ export function AttachmentLightbox({
)}
{hasMultiple && (
<span className="border-l border-white/10 pl-2 text-xs text-white/40">
{openIndex! + 1} / {items.length}
{position} / {items.length}
</span>
)}
</div>
+5 -4
View File
@@ -17,8 +17,9 @@ import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav";
*/
export default function NetworkRoot() {
const { networkId } = useParams();
if (!networkId) throw new Error("NetworkRoot requires a :networkId route param");
const navigate = useNavigate();
const path = particlePath(networkId!, []);
const path = particlePath(networkId, []);
const [composeActive, setComposeActive] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
@@ -68,7 +69,7 @@ export default function NetworkRoot() {
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-14 py-2">
<ParticleListView
streams={streams}
networkId={networkId!}
networkId={networkId}
isLoading={isLoading}
selectedIndex={selectedIndex}
canLoadMore={canLoadMore}
@@ -76,10 +77,10 @@ export default function NetworkRoot() {
/>
</div>
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
<ComposeOverlay networkId={networkId} onActiveChange={setComposeActive} />
{!composeActive && (
<div className="pointer-events-none absolute inset-x-0 bottom-16 z-20 flex justify-center px-3">
<ComposeQuotaIndicator networkId={networkId!} />
<ComposeQuotaIndicator networkId={networkId} />
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
+9 -8
View File
@@ -179,14 +179,15 @@ function Section({ children }: { children: React.ReactNode }) {
export default function NetworkSettingsPage() {
const navigate = useNavigate();
const { networkId } = useParams<{ networkId: string }>();
if (!networkId) throw new Error("NetworkSettingsPage requires a :networkId route param");
const [searchParams] = useSearchParams();
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
const { data: invitations, error: invitationsError } = useNetworkInvitations(networkId!);
const { data: invitations, error: invitationsError } = useNetworkInvitations(networkId);
const currentUser = useAuthStore((s) => s.user);
const isAdmin = currentUser?.id === network?.admin_human.id;
const [memberToRemove, setMemberToRemove] = useState<Human | null>(null);
const removeMember = useRemoveMember(networkId!);
const removeMember = useRemoveMember(networkId);
const billingRef = useRef<HTMLDivElement>(null);
@@ -279,7 +280,7 @@ export default function NetworkSettingsPage() {
}
/>
<Separator />
<InviteForm networkId={networkId!} />
<InviteForm networkId={networkId} />
{invitationsError && (
<>
<Separator />
@@ -288,7 +289,7 @@ export default function NetworkSettingsPage() {
</p>
</>
)}
{pendingCount > 0 && (
{invitations && invitations.length > 0 && (
<>
<Separator />
<div className="px-4 pb-1 pt-3">
@@ -296,13 +297,13 @@ export default function NetworkSettingsPage() {
Pending
</Muted>
</div>
{invitations!.map((inv, index) => (
{invitations.map((inv, index) => (
<div key={inv.email}>
<PendingInvitationRow
email={inv.email}
networkId={networkId!}
networkId={networkId}
/>
{index < invitations!.length - 1 && (
{index < invitations.length - 1 && (
<Separator className="mx-4" />
)}
</div>
@@ -324,7 +325,7 @@ export default function NetworkSettingsPage() {
}
/>
<Separator />
<BillingSection networkId={networkId!} />
<BillingSection networkId={networkId} />
</Section>
</div>
@@ -17,8 +17,9 @@ import { FolderView } from "@/features/particles/folder-view";
*/
export default function ParticleViewResolver() {
const { networkId, "*": rest } = useParams();
if (!networkId) throw new Error("ParticleViewResolver requires a :networkId route param");
const segments = (rest ?? "").split("/").filter(Boolean);
const path = particlePath(networkId!, segments); // path of current container particle
const path = particlePath(networkId, segments); // path of current container particle
const { particle, isLoading, error } = useLiveParticle(path);
@@ -61,7 +61,7 @@ export function ReactionBar({
<div className="flex flex-col items-end gap-1.5">
{/* Emoji reaction pills */}
{activeEmojis.map((emoji) => {
const reactors = reactions![emoji];
const reactors = reactions?.[emoji] ?? [];
const isMine = reactors.includes(currentHumanId);
return (
<Tooltip key={emoji}>
@@ -88,7 +88,7 @@ export function ReactionBar({
{/* Text reaction pills */}
{activeTextReactions.map((text) => {
const reactors = reactions![text];
const reactors = reactions?.[text] ?? [];
const isMine = reactors.includes(currentHumanId);
const firstReactor = resolveHumanDisplay(reactors[0], humans);
const reactorList = getReactorList(reactors, humans, currentHumanId);
+4 -3
View File
@@ -1,12 +1,13 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { useQuery, useMutation, skipToken } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
import type { BillingCadence } from "@/api/types";
export function useNetworkBilling(networkId: string | undefined) {
return useQuery({
queryKey: ["network-billing", networkId],
queryFn: () => apiClient.getNetworkBilling(networkId!),
enabled: !!networkId,
queryFn: networkId
? () => apiClient.getNetworkBilling(networkId)
: skipToken,
// Refetch on window focus so the UI catches up after the user returns
// from Stripe Checkout (webhook may land a second or two later).
// FIX: doesn't work with electron
+8 -11
View File
@@ -36,11 +36,11 @@ export function useChannel(channelId: string | null): UseChannelResult {
};
const onJoin = (msg: { humanId?: string }) => {
if (msg.humanId) {
setPresence((prev) =>
prev.includes(msg.humanId!) ? prev : [...prev, msg.humanId!],
);
}
const humanId = msg.humanId;
if (!humanId) return;
setPresence((prev) =>
prev.includes(humanId) ? prev : [...prev, humanId],
);
};
const onLeave = (msg: { humanId?: string }) => {
@@ -50,12 +50,9 @@ export function useChannel(channelId: string | null): UseChannelResult {
};
const onMessage = (msg: { humanId?: string; payload?: unknown }) => {
if (msg.humanId) {
setMessages((prev) => [
...prev,
{ humanId: msg.humanId!, payload: msg.payload },
]);
}
const humanId = msg.humanId;
if (!humanId) return;
setMessages((prev) => [...prev, { humanId, payload: msg.payload }]);
};
client.on(channelId, "subscribed", onSubscribed);
+4 -3
View File
@@ -1,11 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, skipToken } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
export function useDownloadUrl(objectId?: string) {
return useQuery({
queryKey: ["download-url", objectId],
queryFn: () => apiClient.getParticleDownloadUrl(objectId!),
enabled: !!objectId,
queryFn: objectId
? () => apiClient.getParticleDownloadUrl(objectId)
: skipToken,
staleTime: 1000 * 60 * 60, // 1 hour — signed URLs valid for 24h
});
}
+2 -3
View File
@@ -1,12 +1,11 @@
import { useQueries, useQuery } from "@tanstack/react-query";
import { useQueries, useQuery, skipToken } from "@tanstack/react-query";
import { extractUrls, type LinkMetadata } from "@/lib/link-metadata";
import { platform } from "@/lib/platform";
export function useLinkMetadata(url: string | null) {
return useQuery<LinkMetadata | null>({
queryKey: ["link-metadata", url],
queryFn: () => platform.link.fetchMetadata(url!),
enabled: !!url,
queryFn: url ? () => platform.link.fetchMetadata(url) : skipToken,
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
retry: 1,
+4 -3
View File
@@ -1,4 +1,4 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient, skipToken } from "@tanstack/react-query";
import { useCallback } from "react";
import { apiClient } from "@/api/client";
import type { NetworkUsage } from "@/api/types";
@@ -9,8 +9,9 @@ export const networkUsageQueryKey = (networkId: string | undefined) =>
export function useNetworkUsage(networkId: string | undefined) {
return useQuery({
queryKey: networkUsageQueryKey(networkId),
queryFn: () => apiClient.getNetworkUsage(networkId!),
enabled: !!networkId,
queryFn: networkId
? () => apiClient.getNetworkUsage(networkId)
: skipToken,
// Refetch whenever a consumer mounts (billing settings, compose indicator)
// so users land on fresh quota state without listener wiring.
refetchOnMount: "always",
@@ -1,7 +1,6 @@
import { useEffect, type RefObject } from "react";
import { useNavigate } from "react-router-dom";
import type { MediaParticleHandle } from "@/features/particles/media-particle-view";
import { selectIsPaused, usePlaybackPauseStore } from "@/stores/playback-pause-store";
import { isTypingTarget } from "@/lib/keyboard";
const SEEK_DELTA_SEC = 5;
+1 -1
View File
@@ -181,7 +181,7 @@ export function useStreamPlayback(
lastPersistedMarkerRef.current = currentTime;
const streamDocPath = toFirestoreDocPath(path);
updateStreamPlaybackMarker(streamDocPath, userId, currentTime);
}, [currentParticle?.id, state.initialized, userId, path]);
}, [currentParticle, streamParticle.playback_markers, state.initialized, userId, path]);
// --- Navigation callbacks ---
const next = useCallback(() => {
+11 -11
View File
@@ -110,7 +110,7 @@ function HuddleContent() {
lastAutoPin.current = null;
}
}, [
screenShareTracks.map((t) => `${t.publication.trackSid}_${t.publication.isSubscribed}`).join(),
layoutContext.pin, screenShareTracks
]);
// Sync isSharing state with actual track state
@@ -125,6 +125,15 @@ function HuddleContent() {
};
}, [room]);
const stopScreenShare = useCallback(async () => {
const pub = room.localParticipant.getTrackPublication(Track.Source.ScreenShare);
if (pub?.track) {
await room.localParticipant.unpublishTrack(pub.track.mediaStreamTrack);
pub.track.stop();
}
setIsSharing(false);
}, [room]);
const handleScreenShare = useCallback(
async (sourceId: string) => {
setShowPicker(false);
@@ -148,18 +157,9 @@ function HuddleContent() {
logError(err, { scope: "huddle.screenShare" });
}
},
[room],
[room, stopScreenShare],
);
const stopScreenShare = useCallback(async () => {
const pub = room.localParticipant.getTrackPublication(Track.Source.ScreenShare);
if (pub?.track) {
await room.localParticipant.unpublishTrack(pub.track.mediaStreamTrack);
pub.track.stop();
}
setIsSharing(false);
}, [room]);
return (
<div className="lk-video-conference">
<LayoutContextProvider value={layoutContext} onWidgetChange={setWidgetState}>
+3 -1
View File
@@ -5,5 +5,7 @@ import '@/styles/globals.css';
initSentryRenderer();
const root = createRoot(document.getElementById('root')!);
const container = document.getElementById('root');
if (!container) throw new Error('Root element #root not found');
const root = createRoot(container);
root.render(<HuddleApp />);
+2 -2
View File
@@ -301,7 +301,7 @@ export async function updateParticleProperties<T extends ParticleType>(
const particleRef = typedDoc(docPath);
// Take the partial and create a new object with dot notation
// e.g. { title: "New Title" } becomes { "properties.title": "New Title" }
const updatedProperties: Record<string, any> = {};
const updatedProperties: Record<string, any> = {}; // eslint-disable-line @typescript-eslint/no-explicit-any
for (const key in properties) {
updatedProperties[`properties.${key}`] = properties[key];
}
@@ -352,7 +352,7 @@ export async function updateParticleVisibleTo(
export async function updateParticle(
docPath: string,
fieldName: string,
value: any,
value: any, // eslint-disable-line @typescript-eslint/no-explicit-any
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
+2 -1
View File
@@ -15,7 +15,8 @@ export async function createImageThumbnail(
const h = Math.round(bitmap.height * scale);
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext("2d")!;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Failed to acquire 2D canvas context");
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
+2
View File
@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { useAutoplayPayloadStore } from "@/stores/autoplay-payload-store";
import type { AutoplayPayload } from "@/lib/autoplay-ipc";
import { apiClient } from "@/api/client";
+9 -6
View File
@@ -138,14 +138,17 @@ export class PusherClient {
event: ChannelEventType,
callback: ChannelEventCallback,
): void {
if (!this.listeners.has(channelId)) {
this.listeners.set(channelId, new Map());
let channelListeners = this.listeners.get(channelId);
if (!channelListeners) {
channelListeners = new Map();
this.listeners.set(channelId, channelListeners);
}
const channelListeners = this.listeners.get(channelId)!;
if (!channelListeners.has(event)) {
channelListeners.set(event, new Set());
let eventListeners = channelListeners.get(event);
if (!eventListeners) {
eventListeners = new Set();
channelListeners.set(event, eventListeners);
}
channelListeners.get(event)!.add(callback);
eventListeners.add(callback);
}
off(
+12 -15
View File
@@ -2,7 +2,7 @@ import {
createContext,
useContext,
useEffect,
useRef,
useMemo,
useState,
type ReactNode,
} from "react";
@@ -15,27 +15,24 @@ const PusherStateContext = createContext<ConnectionState>("disconnected");
export function PusherProvider({ children }: { children: ReactNode }) {
const token = useSessionStore((s) => s.token);
const clientRef = useRef<PusherClient | null>(null);
const [connectionState, setConnectionState] =
useState<ConnectionState>("disconnected");
useEffect(() => {
const client = useMemo(() => {
if (!token) {
// Disconnect if token is cleared (logout)
if (clientRef.current) {
clientRef.current.disconnect();
clientRef.current = null;
setConnectionState("disconnected");
}
return;
return null;
}
const client = new PusherClient({
return new PusherClient({
url: appConfig.pusherUrl,
getToken: () => useSessionStore.getState().token,
});
}, [token]);
clientRef.current = client;
useEffect(() => {
if (!client) {
return;
}
const unsubscribeState = client.onStateChange((state) => {
setConnectionState(state);
@@ -46,12 +43,12 @@ export function PusherProvider({ children }: { children: ReactNode }) {
return () => {
unsubscribeState();
client.disconnect();
clientRef.current = null;
setConnectionState("disconnected");
};
}, [token]);
}, [client]);
return (
<PusherContext.Provider value={clientRef.current}>
<PusherContext.Provider value={client}>
<PusherStateContext.Provider value={connectionState}>
{children}
</PusherStateContext.Provider>
+1 -1
View File
@@ -79,7 +79,7 @@ class SoundEffectsEngine {
*/
preload(name: string, url: string): Promise<AudioBuffer | null> {
if (this.buffers.has(name)) {
return Promise.resolve(this.buffers.get(name)!);
return Promise.resolve(this.buffers.get(name));
}
const existing = this.loading.get(name);
if (existing) return existing;
-1
View File
@@ -4,7 +4,6 @@ import started from 'electron-squirrel-startup';
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
import { appConfig } from './config/env';
import { logError } from './lib/errors';
import { safeHandle } from './main/ipc-utils';
import { initSentryMain } from './main/sentry';
+3 -1
View File
@@ -5,5 +5,7 @@ import './styles/globals.css';
initSentryRenderer();
const root = createRoot(document.getElementById('root')!);
const container = document.getElementById('root');
if (!container) throw new Error('Root element #root not found');
const root = createRoot(container);
root.render(<App />);
@@ -5,5 +5,7 @@ import '@/styles/globals.css';
initSentryRenderer();
const root = createRoot(document.getElementById('root')!);
const container = document.getElementById('root');
if (!container) throw new Error('Root element #root not found');
const root = createRoot(container);
root.render(<ScreenRecordControlApp />);
+3 -1
View File
@@ -5,5 +5,7 @@ import "@/styles/globals.css";
initSentryRenderer();
const root = createRoot(document.getElementById("root")!);
const container = document.getElementById("root");
if (!container) throw new Error("Root element #root not found");
const root = createRoot(container);
root.render(<App />);