(fix): live reactions, message and call invites
(fix): call ui
This commit is contained in:
parent
5a26c2d401
commit
f1874042ba
10 changed files with 203 additions and 88 deletions
|
|
@ -332,7 +332,7 @@ function createCallTrayIcon(color: string, speaking: boolean) {
|
|||
}
|
||||
|
||||
function CallInit() {
|
||||
useInitializeCall();
|
||||
const callInvitePopup = useInitializeCall();
|
||||
|
||||
const { load } = useStorage();
|
||||
const {
|
||||
|
|
@ -395,7 +395,7 @@ function CallInit() {
|
|||
});
|
||||
}, [inCall, primaryColor, speaking]);
|
||||
|
||||
return null;
|
||||
return callInvitePopup;
|
||||
}
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
|
|
|
|||
41
packages/cache/src/sync.tsx
vendored
41
packages/cache/src/sync.tsx
vendored
|
|
@ -18,7 +18,6 @@ export default function CacheSync() {
|
|||
const { load } = useStorage();
|
||||
const [accountId, setAccountId] = useState(0);
|
||||
const queueRef = useRef(Promise.resolve());
|
||||
const reactionPartnersRef = useRef(new Map<number, number>());
|
||||
|
||||
useEffect(() => {
|
||||
void load("user_id").then(setAccountId);
|
||||
|
|
@ -195,23 +194,6 @@ export default function CacheSync() {
|
|||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "MessageGet") {
|
||||
const message = result as unknown as CachedMessage;
|
||||
const mappedPartner = reactionPartnersRef.current.get(message.SendTime);
|
||||
reactionPartnersRef.current.delete(message.SendTime);
|
||||
if (mappedPartner) {
|
||||
await insertMessage(mappedPartner, message);
|
||||
return;
|
||||
}
|
||||
const windows = await secureCache().conversations.list();
|
||||
const window = windows.find((candidate) =>
|
||||
candidate.Messages.some(
|
||||
(cached) => cached.SendTime === message.SendTime,
|
||||
),
|
||||
);
|
||||
if (window) await insertMessage(window.UserId, message);
|
||||
}
|
||||
},
|
||||
[accountId, insertMessage, removeMessage, replaceMessage, secureCache],
|
||||
);
|
||||
|
|
@ -259,13 +241,28 @@ export default function CacheSync() {
|
|||
return;
|
||||
}
|
||||
if (message.type === "MessageReactionLive") {
|
||||
reactionPartnersRef.current.set(
|
||||
Number(data.SendTime),
|
||||
Number(data.ChatPartnerId),
|
||||
const partnerId = Number(data.ChatPartnerId);
|
||||
const sendTime = Number(data.SendTime);
|
||||
const senderId = Number(data.SenderId);
|
||||
const reaction = String(data.Reaction);
|
||||
const cache = secureCache();
|
||||
const window = await cache.conversations.get(partnerId);
|
||||
const target = window?.Messages.find(
|
||||
(candidate) => candidate.SendTime === sendTime,
|
||||
);
|
||||
if (!window || !target) return;
|
||||
|
||||
const reactions = (target.Reactions ?? []).filter(
|
||||
(candidate) =>
|
||||
candidate.SenderId !== senderId || candidate.Reaction !== reaction,
|
||||
);
|
||||
if (data.Accepted === true) {
|
||||
reactions.push({ SenderId: senderId, Reaction: reaction });
|
||||
}
|
||||
await replaceMessage(partnerId, sendTime, { Reactions: reactions });
|
||||
}
|
||||
},
|
||||
[accountId, insertMessage, removeMessage, replaceMessage],
|
||||
[accountId, insertMessage, removeMessage, replaceMessage, secureCache],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ export default function InvitePopup({
|
|||
loading={null}
|
||||
component={(user) => (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="flex flex-col gap-5 items-center justify-center w-65 h-80">
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex flex-col gap-5 items-center justify-center w-65 h-80"
|
||||
>
|
||||
<Avatar className="size-30">
|
||||
<AvatarImage src={user.Avatar} />
|
||||
<AvatarFallback className="text-5xl">
|
||||
|
|
@ -36,14 +39,14 @@ export default function InvitePopup({
|
|||
<p className="text-xl font-medium">{user.Display}</p>
|
||||
<div className="w-full flex justify-center gap-3">
|
||||
<Button
|
||||
className="w-14 h-14"
|
||||
className="w-13 h-13!"
|
||||
variant="destructive"
|
||||
onClick={() => onAccept(false)}
|
||||
>
|
||||
<X className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
className="w-14 h-14"
|
||||
className="w-13 h-13!"
|
||||
variant="subtleDefault"
|
||||
onClick={() => onAccept(true)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ export default function Base({
|
|||
flush || fill ? "rounded-none" : "rounded-md"
|
||||
} ${
|
||||
type === "user" && isSpeaking
|
||||
? "border-4 border-(--primary-foreground-alt)/75"
|
||||
? "border-4 border-(--primary-foreground-alt)/75!"
|
||||
: fill
|
||||
? "border-0"
|
||||
: "border"
|
||||
|
|
|
|||
|
|
@ -155,8 +155,8 @@ export function getRoom(): Room {
|
|||
}
|
||||
|
||||
const remoteAudioElements = new Map<string, HTMLMediaElement>();
|
||||
let outgoingJingle: HTMLAudioElement | null = null;
|
||||
let outgoingJingleGeneration = 0;
|
||||
let callJingle: HTMLAudioElement | null = null;
|
||||
let callJingleGeneration = 0;
|
||||
|
||||
const CALL_SECRET_VERSION = 1;
|
||||
const SCREEN_SHARE_PREVIEW_MAX_WIDTH = 320;
|
||||
|
|
@ -164,32 +164,32 @@ const SCREEN_SHARE_PREVIEW_MAX_HEIGHT = 180;
|
|||
const SCREEN_SHARE_PREVIEW_QUALITY = 0.7;
|
||||
const SCREEN_SHARE_PREVIEW_TIMEOUT_MS = 5000;
|
||||
|
||||
async function startOutgoingJingle() {
|
||||
const generation = ++outgoingJingleGeneration;
|
||||
stopSound(outgoingJingle);
|
||||
outgoingJingle = null;
|
||||
async function startCallJingle(shouldPlay: () => boolean) {
|
||||
const generation = ++callJingleGeneration;
|
||||
stopSound(callJingle);
|
||||
callJingle = null;
|
||||
|
||||
const jingle = await requireRuntime(useCall.getState().runtime).load(
|
||||
"settings.call_jingle",
|
||||
);
|
||||
|
||||
if (
|
||||
generation !== outgoingJingleGeneration ||
|
||||
useCall.getState().invitedUserId == null
|
||||
generation !== callJingleGeneration ||
|
||||
!shouldPlay()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
outgoingJingle = playSound(
|
||||
callJingle = playSound(
|
||||
jingle === "jingle_2" ? "call_jingle_2" : "call_jingle_1",
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function stopOutgoingJingle() {
|
||||
outgoingJingleGeneration += 1;
|
||||
stopSound(outgoingJingle);
|
||||
outgoingJingle = null;
|
||||
function stopCallJingle() {
|
||||
callJingleGeneration += 1;
|
||||
stopSound(callJingle);
|
||||
callJingle = null;
|
||||
}
|
||||
|
||||
function protocolBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||
|
|
@ -918,7 +918,7 @@ export async function connect(callId: string) {
|
|||
|
||||
// Tear down the active call session and return the store to a closed state.
|
||||
export async function disconnect() {
|
||||
stopOutgoingJingle();
|
||||
stopCallJingle();
|
||||
disposeSpeakingDetector();
|
||||
await clearScreenSharePreview();
|
||||
|
||||
|
|
@ -993,7 +993,7 @@ export async function joinCall(
|
|||
});
|
||||
|
||||
if (sendInvite && !existingCallId) {
|
||||
void startOutgoingJingle();
|
||||
void startCallJingle(() => useCall.getState().invitedUserId != null);
|
||||
}
|
||||
|
||||
if (callSecret) {
|
||||
|
|
@ -1231,12 +1231,14 @@ export function useInitializeCall() {
|
|||
useCall.setState({
|
||||
incomingCallInvite: { callId, callSecret, senderId },
|
||||
});
|
||||
void startCallJingle(() => useCall.getState().incomingCallInvite != null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setInvitePopupOpen = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
stopCallJingle();
|
||||
useCall.setState({ incomingCallInvite: null });
|
||||
}
|
||||
}, []);
|
||||
|
|
@ -1245,6 +1247,7 @@ export function useInitializeCall() {
|
|||
(accepted: boolean) => {
|
||||
const invite = useCall.getState().incomingCallInvite;
|
||||
|
||||
stopCallJingle();
|
||||
useCall.setState({ incomingCallInvite: null });
|
||||
|
||||
if (!invite) {
|
||||
|
|
@ -1292,6 +1295,11 @@ export function useInitializeCall() {
|
|||
return;
|
||||
}
|
||||
|
||||
const currentCall = useCall.getState();
|
||||
if (currentCall.callId === CallId && currentCall.state !== "closed") {
|
||||
return;
|
||||
}
|
||||
|
||||
showCallingScreen(
|
||||
CallId,
|
||||
normalizeWrappedCallSecret(CallSecret),
|
||||
|
|
@ -1434,7 +1442,7 @@ export function useInitializeCall() {
|
|||
};
|
||||
|
||||
const onDisconnected = () => {
|
||||
stopOutgoingJingle();
|
||||
stopCallJingle();
|
||||
playSound("call_leave");
|
||||
useCall.setState({ state: "closed" });
|
||||
syncParticipantState();
|
||||
|
|
@ -1445,7 +1453,7 @@ export function useInitializeCall() {
|
|||
};
|
||||
|
||||
const onParticipantConnected = () => {
|
||||
stopOutgoingJingle();
|
||||
stopCallJingle();
|
||||
playSound("call_join");
|
||||
syncAllRemoteTrackSubscriptions();
|
||||
syncParticipantState();
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ export default function View() {
|
|||
}
|
||||
>
|
||||
<Base
|
||||
fill={isImmersiveFocusedView}
|
||||
fill
|
||||
flush={isImmersiveFocusedView || isFocusedTileFlush}
|
||||
type={focusedTileType}
|
||||
participant={focusedParticipant}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,34 @@ function updateMessagesBySendTime<T extends EditableMessage>(
|
|||
};
|
||||
}
|
||||
|
||||
function updateMessageReaction<T extends EditableMessage>(
|
||||
message: T,
|
||||
reaction: string,
|
||||
senderId: number,
|
||||
accepted: boolean,
|
||||
): T {
|
||||
const current = message.Reactions ?? [];
|
||||
const withoutReaction = current.filter(
|
||||
(item) => item.Reaction !== reaction || item.SenderId !== senderId,
|
||||
);
|
||||
const next = accepted
|
||||
? [...withoutReaction, { Reaction: reaction, SenderId: senderId }]
|
||||
: withoutReaction;
|
||||
|
||||
if (
|
||||
next.length === current.length &&
|
||||
next.every(
|
||||
(item, index) =>
|
||||
item.Reaction === current[index]?.Reaction &&
|
||||
item.SenderId === current[index]?.SenderId,
|
||||
)
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return { ...message, Reactions: next };
|
||||
}
|
||||
|
||||
function assertProtocolSuccess(type: string, response: { type: string }) {
|
||||
if (response.type.startsWith("Error")) {
|
||||
throw new Error(`${type} failed: ${response.type}`);
|
||||
|
|
@ -553,6 +581,51 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
[currentChatSecret, userIdValue],
|
||||
);
|
||||
|
||||
const applyLiveReaction = useCallback(
|
||||
(
|
||||
sendTime: number,
|
||||
reaction: string,
|
||||
senderId: number,
|
||||
accepted: boolean,
|
||||
) => {
|
||||
setLiveMessagesState((current) =>
|
||||
current.map((message) =>
|
||||
message.SendTime === sendTime
|
||||
? updateMessageReaction(message, reaction, senderId, accepted)
|
||||
: message,
|
||||
),
|
||||
);
|
||||
|
||||
const queryKey = [
|
||||
"chat-messages",
|
||||
String(userIdValue),
|
||||
currentChatSecret !== null,
|
||||
] as const;
|
||||
queryClient.setQueryData<InfiniteData<RawMessages>>(
|
||||
queryKey,
|
||||
(current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
pages: current.pages.map((page) =>
|
||||
page.map((message) =>
|
||||
message.SendTime === sendTime
|
||||
? updateMessageReaction(
|
||||
message,
|
||||
reaction,
|
||||
senderId,
|
||||
accepted,
|
||||
)
|
||||
: message,
|
||||
),
|
||||
),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
},
|
||||
[currentChatSecret, userIdValue],
|
||||
);
|
||||
|
||||
const deleteMessage = useCallback(
|
||||
async (sendTime: number) => {
|
||||
try {
|
||||
|
|
@ -743,20 +816,28 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
const rawData = message.data as {
|
||||
ChatPartnerId: unknown;
|
||||
SendTime: unknown;
|
||||
Reaction: string;
|
||||
SenderId: unknown;
|
||||
Accepted: boolean;
|
||||
};
|
||||
const chatPartnerId = Number(rawData.ChatPartnerId);
|
||||
const sendTime = Number(rawData.SendTime);
|
||||
const senderId = Number(rawData.SenderId);
|
||||
|
||||
if (chatPartnerId !== userIdValue || !Number.isFinite(sendTime)) return;
|
||||
if (
|
||||
chatPartnerId !== userIdValue ||
|
||||
!Number.isFinite(sendTime) ||
|
||||
!Number.isFinite(senderId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
void send("MessageGet", { SendTime: sendTime })
|
||||
.then((response) => {
|
||||
assertProtocolSuccess("MessageGet", response);
|
||||
editMessage(sendTime, { Reactions: response.data.Reactions ?? [] });
|
||||
})
|
||||
.catch((err) => {
|
||||
log(1, "chat", "red", "Failed to refresh message reactions", err);
|
||||
});
|
||||
applyLiveReaction(
|
||||
sendTime,
|
||||
rawData.Reaction,
|
||||
senderId,
|
||||
rawData.Accepted,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -819,9 +900,9 @@ export default function Provider({ children }: { children: ReactNode }) {
|
|||
});
|
||||
}, [
|
||||
currentChatSecret,
|
||||
applyLiveReaction,
|
||||
editMessage,
|
||||
removeMessage,
|
||||
send,
|
||||
subscribePush,
|
||||
userIdValue,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -57,7 +57,19 @@ export type BoundSendFn = <T extends keyof Schemas & string>(
|
|||
options?: { id?: number },
|
||||
) => Promise<ProtocolMessage<T>>;
|
||||
|
||||
export type PushHandler = (message: ProtocolMessage) => void;
|
||||
export type PushHandler = (
|
||||
message: ProtocolMessage,
|
||||
) => void | Promise<void>;
|
||||
|
||||
const PUSH_TYPES = [
|
||||
"MessageLive",
|
||||
"MessageEditLive",
|
||||
"MessageReactionLive",
|
||||
"MessageDeleteLive",
|
||||
"MessageState",
|
||||
"CallInvite",
|
||||
"ErrorNoIota",
|
||||
] as const;
|
||||
|
||||
export type MTPExchange = {
|
||||
type: keyof Schemas & string;
|
||||
|
|
@ -157,6 +169,7 @@ export function Provider(props: {
|
|||
null,
|
||||
);
|
||||
const interceptorsRef = useRef(new Set<MTPInterceptor>());
|
||||
const pushHandlersRef = useRef(new Set<PushHandler>());
|
||||
|
||||
const connected = readyState === ConnectionState.Connected;
|
||||
|
||||
|
|
@ -197,28 +210,8 @@ export function Provider(props: {
|
|||
}, []);
|
||||
|
||||
const subscribePush = useCallback((handler: PushHandler) => {
|
||||
const client = clientRef.current;
|
||||
if (!client) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const unsubscribers = [
|
||||
"MessageLive",
|
||||
"MessageEditLive",
|
||||
"MessageReactionLive",
|
||||
"MessageDeleteLive",
|
||||
"MessageState",
|
||||
"CallInvite",
|
||||
"ErrorNoIota",
|
||||
].map((type) =>
|
||||
client.subscribe(type, (message) => {
|
||||
handler(validateResponse(type as keyof Schemas & string, message));
|
||||
}),
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribers.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
pushHandlersRef.current.add(handler);
|
||||
return () => pushHandlersRef.current.delete(handler);
|
||||
}, []);
|
||||
|
||||
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
|
||||
|
|
@ -419,6 +412,28 @@ export function Provider(props: {
|
|||
const activeClient = client;
|
||||
|
||||
clientRef.current = activeClient;
|
||||
for (const type of PUSH_TYPES) {
|
||||
activeClient.subscribe(type, (message) => {
|
||||
let validated: ProtocolMessage;
|
||||
try {
|
||||
validated = validateResponse(type, message);
|
||||
} catch (error) {
|
||||
log(1, "mtp", "red", "Failed to validate push message", error, {
|
||||
type,
|
||||
data: message.data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const handler of [...pushHandlersRef.current]) {
|
||||
void Promise.resolve()
|
||||
.then(() => handler(validated))
|
||||
.catch((error) => {
|
||||
log(1, "mtp", "red", "Push handler failed", error, { type });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
setReadyState(activeClient.state);
|
||||
|
||||
clearReconnectTimer();
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
const { subscribePush, send } = useMTP();
|
||||
const { load } = useStorage();
|
||||
const { get } = useUser();
|
||||
const { addLiveMessage, getChatSecret, userId } = useChat();
|
||||
const { addLiveMessage, chatSecret, getChatSecret, userId } = useChat();
|
||||
const { moveUserIdToTop } = useSession();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -42,13 +42,15 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
|
||||
if (!data.SenderId) return;
|
||||
|
||||
const chatSecret = await getChatSecret(data.SenderId);
|
||||
const messageSecret =
|
||||
userId === data.SenderId && chatSecret
|
||||
? chatSecret
|
||||
: await getChatSecret(data.SenderId);
|
||||
|
||||
if (!data.Message || !chatSecret) return;
|
||||
if (!data.Message || !messageSecret) return;
|
||||
playSound("message");
|
||||
const user = await get(data.SenderId);
|
||||
|
||||
void decryptChatText(chatSecret, data.Message.Content)
|
||||
void decryptChatText(messageSecret, data.Message.Content)
|
||||
.catch((err) => {
|
||||
log(1, "chat", "red", "Failed to decrypt live message", err, {
|
||||
SendTime: data.Message?.SendTime,
|
||||
|
|
@ -69,6 +71,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
|
||||
// todo: add notification symbol to conversation cards (incl. message start)
|
||||
moveUserIdToTop(data.SenderId);
|
||||
const user = await get(data.SenderId);
|
||||
|
||||
if (await load("settings.receive_confirmations")) {
|
||||
void send(
|
||||
|
|
@ -127,6 +130,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
});
|
||||
}, [
|
||||
addLiveMessage,
|
||||
chatSecret,
|
||||
get,
|
||||
load,
|
||||
navigate,
|
||||
|
|
|
|||
|
|
@ -277,14 +277,21 @@ export const mtp = {
|
|||
Reaction: z.string(),
|
||||
SendTime: z.number(),
|
||||
ChatPartnerId: z.number(),
|
||||
SenderId: z.number(),
|
||||
Accepted: z.boolean(),
|
||||
}),
|
||||
},
|
||||
MessageLive: {
|
||||
request: z.object({}).optional(),
|
||||
response: z.object({
|
||||
SenderId: z.number(),
|
||||
Message,
|
||||
}),
|
||||
response: z
|
||||
.object({
|
||||
SenderId: z.number(),
|
||||
Message: Message.extend({ SenderId: z.number().optional() }),
|
||||
})
|
||||
.transform(({ SenderId, Message }) => ({
|
||||
SenderId,
|
||||
Message: { ...Message, SenderId: Message.SenderId ?? SenderId },
|
||||
})),
|
||||
},
|
||||
MessageGet: {
|
||||
request: z.object({
|
||||
|
|
|
|||
Loading…
Reference in a new issue