Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions discord/src/components/controls/ControlWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,41 @@ interface ControlWrapperProps {
iconDisabled: React.ReactNode;
onClick: () => void;
isEnabled: boolean;
isLoading?: boolean;
}

export const ControlWrapper: React.FC<ControlWrapperProps> = ({
iconEnabled,
iconDisabled,
onClick,
isEnabled,
isLoading = false,
}) => {
const LoadingSpinner = () => (
<svg
className="w-6 h-6 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
role="img"
aria-label="Loading"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
/>
</svg>
);

return (
<button
onClick={onClick}
Expand All @@ -23,11 +50,12 @@ export const ControlWrapper: React.FC<ControlWrapperProps> = ({
onClick();
}
}}
className={`flex items-center justify-center w-full h-full rounded-2xl transition-colors cursor-pointer focus:outline-none focus-visible:ring-0 ${
className={`flex items-center justify-center w-full h-full rounded-2xl transition-colors focus:outline-none focus-visible:ring-0 ${
isEnabled ? "bg-transparent" : "bg-red-500/50"
}`}
} ${isLoading ? "cursor-not-allowed opacity-70" : "cursor-pointer"}`}
disabled={isLoading}
>
{isEnabled ? iconEnabled : iconDisabled}
{isLoading ? <LoadingSpinner /> : isEnabled ? iconEnabled : iconDisabled}
</button>
);
};
8 changes: 7 additions & 1 deletion discord/src/components/controls/DeafenButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,20 @@ import { ControlWrapper } from "./ControlWrapper"
export const DeafenButton = () => {
useInitializeCallStore()
const toggleDeafen = useControlStore((state) => state.toggleDeafen)
const isDeafened = useCallStore((state) => state.callStatus?.user?.isDeafened) || false
const { callStatus, isLoading } = useCallStore((state) => ({
callStatus: state.callStatus,
isLoading: state.isLoading,
}))
const isDeafened = callStatus?.user?.isDeafened || false
const showLoading = isLoading && !callStatus

return (
<ControlWrapper
iconEnabled={<IconDeafenedOffDiscord className="w-full h-full text-white fill-white" />}
iconDisabled={<IconDeafenedDiscord className="w-full h-full text-red-500 fill-red-500" />}
onClick={toggleDeafen}
isEnabled={!isDeafened}
isLoading={showLoading}
/>
)
}
8 changes: 7 additions & 1 deletion discord/src/components/controls/MuteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,20 @@ import { ControlWrapper } from "./ControlWrapper"
export const MuteButton = () => {
useInitializeCallStore()
const toggleMute = useControlStore((state) => state.toggleMute)
const isMuted = useCallStore((state) => state.callStatus?.user?.isMuted) || false
const { callStatus, isLoading } = useCallStore((state) => ({
callStatus: state.callStatus,
isLoading: state.isLoading,
}))
const isMuted = callStatus?.user?.isMuted || false
const showLoading = isLoading && !callStatus

return (
<ControlWrapper
iconEnabled={<IconMicDiscord className="w-full h-full text-white fill-white" />}
iconDisabled={<IconMicOffDiscord className="w-full h-full text-red-500 fill-red-500" />}
onClick={toggleMute}
isEnabled={!isMuted}
isLoading={showLoading}
/>
)
}
72 changes: 72 additions & 0 deletions discord/src/stores/callStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ type CallStoreState = {
callStatus: CallStatus | null;
selfUserId: string | null;
pollingIntervalId: ReturnType<typeof setInterval> | null;
updateLocalVoiceState: (update: {
isMuted?: boolean;
isDeafened?: boolean;
}) => void;
initialize: () => void;
refreshCallStatus: () => void;
setCallStatus: (callStatus: CallStatus) => void;
Expand Down Expand Up @@ -127,6 +131,73 @@ export const useCallStore = create<CallStoreState>((set, get) => {
callStatus: null,
pollingIntervalId: null,

updateLocalVoiceState: (update) => {
set((state) => {
if (!state.callStatus) return {};

const targetUserId = state.callStatus.user?.id ?? state.selfUserId;
if (!targetUserId) return {};

const participantExists = state.callStatus.participants.some(
(participant) => participant.id === targetUserId
);

const updatedParticipants = state.callStatus.participants.map(
(participant) =>
participant.id === targetUserId
? {
...participant,
...(update.isMuted !== undefined
? { isMuted: update.isMuted }
: {}),
...(update.isDeafened !== undefined
? { isDeafened: update.isDeafened }
: {}),
}
: participant
);

const participants = participantExists
? updatedParticipants
: [
...updatedParticipants,
{
id: targetUserId,
username:
state.callStatus.user?.username ??
state.callStatus.user?.id ??
targetUserId,
profileUrl: state.callStatus.user?.profileUrl,
isSpeaking: false,
isMuted: update.isMuted ?? false,
isDeafened: update.isDeafened ?? false,
},
];

const nextUser =
state.callStatus.user && state.callStatus.user.id === targetUserId
? {
...state.callStatus.user,
...(update.isMuted !== undefined
? { isMuted: update.isMuted }
: {}),
...(update.isDeafened !== undefined
? { isDeafened: update.isDeafened }
: {}),
}
: state.callStatus.user;

return {
callStatus: {
...state.callStatus,
participants,
...(nextUser ? { user: nextUser } : {}),
},
selfUserId: targetUserId,
};
});
},

initialize: () => {
if (get().initialized) return;
set({ initialized: true });
Expand Down Expand Up @@ -215,6 +286,7 @@ export const useCallStore = create<CallStoreState>((set, get) => {
selfUserId: shouldUpdateUser
? event.payload.userId
: state.selfUserId,
isLoading: false,
};
});
}
Expand Down
11 changes: 11 additions & 0 deletions discord/src/stores/controlStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createDeskThing } from '@deskthing/client';
import { DISCORD_ACTIONS } from '@shared/types/discord';
import { ToClientTypes, ToServerTypes } from '@shared/types/transit';
import { DISCORD_APP_ID } from '@src/constants/app';
import { useCallStore } from './callStore';

const DeskThing = createDeskThing<ToClientTypes, ToServerTypes>();
const APP_ID = DISCORD_APP_ID;
Expand All @@ -28,26 +29,36 @@ interface ControlStore {
export const useControlStore = create<ControlStore>(() => ({
mute: () => {
DeskThing.debug('Muting user');
useCallStore.getState().updateLocalVoiceState({ isMuted: true });
DeskThing.triggerAction({ id: DISCORD_ACTIONS.MUTE, value: 'mute', source: APP_ID });
},
unmute: () => {
DeskThing.debug('Unmuting user');
useCallStore.getState().updateLocalVoiceState({ isMuted: false });
DeskThing.triggerAction({ id: DISCORD_ACTIONS.MUTE, value: 'unmute', source: APP_ID });
},
toggleMute: () => {
DeskThing.debug('Toggling mute');
const currentState = useCallStore.getState();
const isMuted = currentState.callStatus?.user?.isMuted ?? false;
currentState.updateLocalVoiceState({ isMuted: !isMuted });
DeskThing.triggerAction({ id: DISCORD_ACTIONS.MUTE, value: 'toggle', source: APP_ID });
},
deafen: () => {
DeskThing.debug('Deafening user');
useCallStore.getState().updateLocalVoiceState({ isDeafened: true });
DeskThing.triggerAction({ id: DISCORD_ACTIONS.DEAFEN, value: 'deafen', source: APP_ID });
},
undeafen: () => {
DeskThing.debug('Undeafening user');
useCallStore.getState().updateLocalVoiceState({ isDeafened: false });
DeskThing.triggerAction({ id: DISCORD_ACTIONS.DEAFEN, value: 'undeafen', source: APP_ID });
},
toggleDeafen: () => {
DeskThing.debug('Toggling deafen');
const currentState = useCallStore.getState();
const isDeafened = currentState.callStatus?.user?.isDeafened ?? false;
currentState.updateLocalVoiceState({ isDeafened: !isDeafened });
DeskThing.triggerAction({ id: DISCORD_ACTIONS.DEAFEN, value: 'toggle', source: APP_ID });
},
disconnect: () => {
Expand Down