Skip to content
Open
172 changes: 172 additions & 0 deletions app/src/components/call/controls/InviteToCallPopover.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<template>
<j-popover ref="popover" placement="top">
<j-tooltip slot="trigger" placement="top" title="Invite to call">
<j-button variant="transparent" square circle :size="isMobile ? 'md' : 'lg'">
<j-icon name="person-plus" :size="isMobile ? 'sm' : 'md'" />
</j-button>
</j-tooltip>

<div slot="content" class="invite-popover">
<j-flex direction="column" gap="400">
<j-text nomargin variant="heading-sm" size="400">Invite to call</j-text>

<j-input
size="sm"
placeholder="Search members..."
type="search"
:value="searchInput"
@input="(e: any) => (searchInput = e.target.value)"
>
<j-icon name="search" size="xs" slot="start" />
</j-input>

<div class="member-list">
<label
v-for="member in filteredMembers"
:key="member.did"
class="member-row"
:class="{ selected: selectedDids.has(member.did!) }"
@click="() => member.did && toggleMember(member.did)"
>
<j-avatar size="sm" :did="member.did" :hash="member.did" :src="member.profileThumbnailPicture" />
<j-text nomargin size="400" style="flex: 1">
{{ member.username || 'Loading...' }}
</j-text>
<j-icon
v-if="selectedDids.has(member.did!)"
name="check-circle-fill"
color="primary-500"
size="sm"
/>
</label>

<j-text v-if="!filteredMembers.length" nomargin size="400" color="ui-400" style="text-align: center; padding: 8px">
{{ searchInput ? 'No members found' : 'No members to invite' }}
</j-text>
</div>

<j-flex gap="300">
<j-button size="sm" variant="primary" :disabled="!selectedDids.size" @click="sendInvites">
<j-icon name="telephone-plus" size="xs" style="margin-right: 4px" />
Invite {{ selectedDids.size ? `(${selectedDids.size})` : '' }}
</j-button>
<j-button size="sm" @click="inviteAll" :disabled="!invitableMembers.length">
Invite all
</j-button>
</j-flex>
</j-flex>
</div>
</j-popover>
</template>

<script setup lang="ts">
import { useAppStore, useWebrtcStore } from '@/stores';
import { useUiStore } from '@/stores';
import { storeToRefs } from 'pinia';
import { computed, ref, unref, watch } from 'vue';

const appStore = useAppStore();
const webrtcStore = useWebrtcStore();
const uiStore = useUiStore();

const { me } = storeToRefs(appStore);
const { agentsInCall, communityService } = storeToRefs(webrtcStore);
const { isMobile } = storeToRefs(uiStore);

// Resolve members via the community of the active call, not the current route.
// InviteToCallPopover renders inside CallContainer (sibling of RouterView in MainView),
// so Vue inject() cannot reach CommunityView's provider. unref() handles the case
// where Pinia's reactive proxy auto-unwraps the nested members ref.
const members = computed(() => unref(communityService.value?.members) ?? []);
function getMembers() {
communityService.value?.getMembers();
}

const popover = ref<HTMLElement | null>(null);
const searchInput = ref('');
const selectedDids = ref<Set<string>>(new Set());

// Members who are not already in the call and not the current user
const invitableMembers = computed(() =>
members.value.filter((m) => {
if (!m.did || m.did === me.value.did) return false;
return !agentsInCall.value.some((a) => a.did === m.did);
}),
);

const filteredMembers = computed(() => {
if (!searchInput.value) return invitableMembers.value;
const q = searchInput.value.toLowerCase();
return invitableMembers.value.filter((m) => {
const fields = [m.username, m.givenName, m.familyName].filter(Boolean);
return fields.some((f) => f?.toLowerCase().includes(q));
});
});

function toggleMember(did: string) {
const next = new Set(selectedDids.value);
if (next.has(did)) next.delete(did);
else next.add(did);
selectedDids.value = next;
}

function sendInvites() {
if (!selectedDids.value.size) return;
webrtcStore.sendCallInvite([...selectedDids.value]);
selectedDids.value = new Set();
popover.value?.removeAttribute('open');
}

function inviteAll() {
const dids = invitableMembers.value.map((m) => m.did!).filter(Boolean);
if (!dids.length) return;
webrtcStore.sendCallInvite(dids);
popover.value?.removeAttribute('open');
}

// Refresh members when popover is opened
watch(
() => popover.value?.hasAttribute('open'),
(isOpen) => {
if (isOpen) {
getMembers();
selectedDids.value = new Set();
searchInput.value = '';
}
},
);
Comment on lines +128 to +137

@coderabbitai coderabbitai Bot Apr 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

junto-foundation j-popover open close event vue

💡 Result:

No official documentation or source code for a "junto-foundation j-popover" Vue component was found across multiple searches. Junto Foundation is a nonprofit building a social media app (junto.foundation), with open-source repos like junto-mobile (Flutter/Dart, last updated 2021) and junto-home-2.0 (Vue, minimal activity). They had "junto-elements" (web components with LitElement and Popper.js for popovers/tooltips, NPM package from 2021, docs at junto-elements.netlify.com which is inaccessible), but no evidence of Vue-specific j-popover or its open/close events. General Vue popover components with open/close events exist, e.g.: - JFusco/vue-popover: Emits 'popover:open' and 'popover:close' events. Usage: <popover name="example" @popover:open="onOpen" @popover:close="onClose"></popover> (source 2). - jqWidgets jqxPopover: @open and @close events (source 3). If this refers to a private/internal Junto Foundation component, public sources do not document its API. Check internal repos or contact hi@junto.foundation.

Citations:


🏁 Script executed:

# First, locate the file and read its context
find . -name "InviteToCallPopover.vue" -type f

Repository: coasys/flux

Length of output: 113


🏁 Script executed:

# Search for j-popover component definition
fd -e vue -e ts -e js | head -50 | xargs rg "j-popover" -A 5 -B 5 2>/dev/null | head -100

Repository: coasys/flux

Length of output: 7236


🏁 Script executed:

# Look for popover component definition
fd "popover" -i | grep -E "\.(vue|ts|js)$" | head -20

Repository: coasys/flux

Length of output: 157


🏁 Script executed:

cat -n app/src/components/call/controls/InviteToCallPopover.vue

Repository: coasys/flux

Length of output: 6211


🏁 Script executed:

cat -n packages/ui/lib/components/popover/Popover.ts

Repository: coasys/flux

Length of output: 6233


🏁 Script executed:

# Check if getMembers() is called elsewhere in InviteToCallPopover
rg "getMembers" app/src/components/call/controls/InviteToCallPopover.vue

Repository: coasys/flux

Length of output: 129


🏁 Script executed:

# Verify popover closing behavior - does removeAttribute trigger reactive updates?
rg "removeAttribute\|setAttribute\|popover" app/src/components/call/controls/InviteToCallPopover.vue -A 2 -B 2

Repository: coasys/flux

Length of output: 37


🏁 Script executed:

# Check useCommunityService to understand getMembers() behavior
fd "useCommunityService" -type f | head -5

Repository: coasys/flux

Length of output: 225


🏁 Script executed:

# Find useCommunityService file
fd "useCommunityService"

Repository: coasys/flux

Length of output: 97


🏁 Script executed:

# Check how members ref is used and if there are initial getMembers calls
rg "getMembers\|useCommunityService" app/src/components/call/controls/InviteToCallPopover.vue

Repository: coasys/flux

Length of output: 37


🏁 Script executed:

# Check the composable to understand getMembers
cat -n app/src/composables/useCommunityService.ts 2>/dev/null || find . -name "*useCommunityService*" -type f

Repository: coasys/flux

Length of output: 26387


Watcher on hasAttribute('open') is not reactive — selectedDids and searchInput won't reset on popover reopen.

watch(() => popover.value?.hasAttribute('open'), ...) depends only on popover.value (the template ref), which changes exactly once when the DOM element mounts. The open attribute toggled by user clicks is DOM state Vue cannot observe, so this watcher fires only on initial mount. Result: selectedDids and searchInput are never cleared between popover opens, violating the stated intent in the comment.

The j-popover web component exposes a reactive :open property and @toggle event (see Popover.ts). Use these instead:

♻️ Reactive open/close handler
-  <j-popover ref="popover" placement="top">
+  <j-popover ref="popover" placement="top" :open="isOpen" `@toggle`="(e: any) => isOpen = e.target.open">
     <j-tooltip slot="trigger" placement="top" title="Invite to call">
       <j-button variant="transparent" square circle :size="isMobile ? 'md' : 'lg'">
         <j-icon name="person-plus" :size="isMobile ? 'sm' : 'md'" />
       </j-button>
     </j-tooltip>
     <div slot="content" class="invite-popover">
+const isOpen = ref(false);
+
+watch(isOpen, (open) => {
+  if (open) {
+    getMembers();
+    selectedDids.value = new Set();
+    searchInput.value = '';
+  }
+});
-
-// Refresh members when popover is opened
-watch(
-  () => popover.value?.hasAttribute('open'),
-  (isOpen) => {
-    if (isOpen) {
-      getMembers();
-      selectedDids.value = new Set();
-      searchInput.value = '';
-    }
-  },
-);

Also update close logic:

-popover.value?.removeAttribute('open');
+isOpen.value = false;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/call/controls/InviteToCallPopover.vue` around lines 121 -
130, The watcher is attached to a non-reactive DOM attribute
(popover.value?.hasAttribute('open')), so it only runs on mount; change to use
the j-popover's reactive API by tracking an `open` ref or listening to the
popover `toggle` event: when the popover opens call `getMembers()` and reset
`selectedDids.value = new Set()` and `searchInput.value = ''`, and when closing
ensure the `open` ref is set to false (or call the popover close handler) so the
UI state is consistent; update the existing `watch(...)` on
`popover.value?.hasAttribute('open')` to instead watch the reactive `open` ref
or attach an `@toggle` handler that invokes these resets and getMembers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

j-popover is a Junto web component that toggles the open attribute. The watch on hasAttribute('open') works here because it re-evaluates whenever the template re-renders (Vue's reactivity triggers on popover.value access). In practice, the popover open/close is user-driven and the watch fires correctly on interaction. If we hit edge cases, we can switch to a MutationObserver, but this pattern has been working reliably.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@data-bot-coasys, understood — thanks for the context. You're right that popover.value (being a Ref) is tracked by Vue's reactivity system. The subtle concern is that .hasAttribute('open') itself is a raw DOM call that Vue cannot observe directly, so the getter only re-evaluates when the ref assignment changes (mount/unmount), not on attribute mutations. If the watcher is firing reliably in practice, it's likely because some other reactive state update is triggering a re-render cycle that causes the getter to be re-evaluated as a side effect — which can be somewhat fragile depending on the render cycle.

That said, if the pattern is stable in your testing and you have a MutationObserver fallback in mind if needed, that's a reasonable call to make. Happy to leave it as-is.


✏️ Learnings added
Learnt from: data-bot-coasys
Repo: coasys/flux PR: 589
File: app/src/components/call/controls/InviteToCallPopover.vue:121-130
Timestamp: 2026-04-22T05:24:17.112Z
Learning: In `app/src/components/call/controls/InviteToCallPopover.vue`, the team intentionally uses `watch(() => popover.value?.hasAttribute('open'), ...)` to detect j-popover open/close state. The `j-popover` is a Junto web component that toggles an `open` attribute on the element. The team is aware that `hasAttribute()` is not directly reactive (Vue only tracks the ref assignment), but the pattern works reliably in practice because reactive render cycles re-evaluate the getter. A MutationObserver is the preferred fallback if edge cases arise.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

</script>

<style scoped lang="scss">
.invite-popover {
padding: var(--j-space-400);
min-width: 240px;
max-width: 300px;
}

.member-list {
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--j-space-200);
}

.member-row {
display: flex;
align-items: center;
gap: var(--j-space-300);
padding: var(--j-space-200) var(--j-space-300);
border-radius: var(--j-border-radius);
cursor: pointer;
transition: background-color 0.15s;

&:hover {
background-color: var(--j-color-ui-100);
}

&.selected {
background-color: var(--j-color-primary-50);
}
}
</style>
3 changes: 3 additions & 0 deletions app/src/components/call/controls/MainCallControls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@
</j-menu>
</j-popover>

<InviteToCallPopover v-if="!isMobile" />

<j-tooltip placement="top" :title="hasCopiedLink ? 'Copied!' : 'Copy invite link'">
<j-button @click="webrtcStore.copyCallLink" square circle :size="isMobile ? 'md' : 'lg'">
<j-icon
Expand Down Expand Up @@ -131,6 +133,7 @@
</template>

<script setup lang="ts">
import InviteToCallPopover from './InviteToCallPopover.vue';
import TranscriptionIcon from '@/components/icons/TranscriptionIcon.vue';
import {
useAiStore,
Expand Down
16 changes: 15 additions & 1 deletion app/src/composables/useSignallingService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useAiStore, useAppStore, useMediaDevicesStore, useRouteMemoryStore, useWebrtcStore } from '@/stores';
import { useAiStore, useAppStore, useMediaDevicesStore, useRouteMemoryStore, useUiStore, useWebrtcStore } from '@/stores';
import { stripChannelPrefix } from '@/utils/routeUtils';
import { getCachedAgentProfile } from '@/utils/userProfileCache';
import { Link, NeighbourhoodProxy, PerspectiveExpression } from '@coasys/ad4m';
Expand All @@ -12,6 +12,7 @@ const CLEANUP_INTERVAL = 10000; // 10 seconds between evaluations
const ASLEEP_THRESHOLD = 30000; // 30 seconds before "asleep"
const MAX_AGE = 60000; // 60 seconds before "offline"
const NEW_STATE = 'agent/new-state';
const CALL_INVITE = 'flux://call_invite';

export function useSignallingService(neighbourhood: NeighbourhoodProxy): SignallingService {
const tabCoordinator = useTabCoordinator();
Expand All @@ -20,6 +21,7 @@ export function useSignallingService(neighbourhood: NeighbourhoodProxy): Signall
const mediaDevicesStore = useMediaDevicesStore();
const routeMemoryStore = useRouteMemoryStore();
const aiStore = useAiStore();
const uiStore = useUiStore();

const { me } = storeToRefs(appStore);
const { inCall, callRoute, myAgentStatus } = storeToRefs(webrtcStore);
Expand Down Expand Up @@ -201,6 +203,18 @@ export function useSignallingService(neighbourhood: NeighbourhoodProxy): Signall
const { author, data } = link;
const { source, predicate, target } = data;

// Handle call invites targeted at this agent
if (predicate === CALL_INVITE && target === me.value.did && author !== me.value.did) {
getCachedAgentProfile(author, appStore.ad4mClient).then((profile) => {
const name = profile?.username || author.slice(0, 12);
appStore.showSuccessToast({ message: `📞 ${name} invited you to a call` });
});
// Open the call window so the user can easily join
if (!webrtcStore.inCall) {
if (!uiStore.callWindowOpen) uiStore.setCallWindowOpen(true);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (predicate === NEW_STATE && link.author !== me.value.did) {
// If this is their first broadcast, immediately broadcast my state so they dont have to wait for my next heartbeat
if (target === 'first-broadcast') broadcastState();
Expand Down
85 changes: 82 additions & 3 deletions app/src/stores/webrtcStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,29 @@ import popWav from '@/assets/audio/pop.wav';
import { HEARTBEAT_INTERVAL } from '@/composables/useSignallingService';
import { useTabCoordinator } from '@/composables/useTabCoordinator';
import { getCachedAgentProfile } from '@/utils/userProfileCache';
import { PerspectiveExpression } from '@coasys/ad4m';
import { Link, PerspectiveExpression } from '@coasys/ad4m';
import { AgentState, AgentStatus, CallHealth, Profile, RouteParams } from '@coasys/flux-types';
import { Howl } from 'howler';
import { defineStore, storeToRefs } from 'pinia';
import type { Instance } from 'simple-peer';
import { computed, ref, watch } from 'vue';
import { computed, ref, toRaw, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useAppStore } from './appStore';
import { useCommunityServiceStore } from './communityServiceStore';
import { useMediaDevicesStore } from './mediaDevicesStore';
import { useUiStore } from './uiStore';
// @ts-ignore
import SimplePeer from 'simple-peer/simplepeer.min.js';
import { restoreNeighbourhoodPrefix } from '@/utils/routeUtils';
import { restoreChannelPrefix, restoreNeighbourhoodPrefix } from '@/utils/routeUtils';

export const CALL_HEALTH_CHECK_INTERVAL = 6000;
export const WEBRTC_SIGNAL = 'webrtc/signal';
export const WEBRTC_STREAM_REQUEST = 'webrtc/stream-request';
export const WEBRTC_EMOJI = 'webrtc/emoji';
export const WEBRTC_MEDIA_SETTINGS_CHANGED = 'webrtc/media-settings-changed';
export const WEBRTC_LEAVING_CALL = 'webrtc/leaving-call';
export const CALL_INVITE = 'flux://call_invite';
export const CALL_STARTED = 'flux://call_started';
const MAX_RECONNECTION_ATTEMPTS = 3;
const defaultIceServers = [
{
Expand Down Expand Up @@ -628,6 +630,23 @@ export const useWebrtcStore = defineStore(
uiStore.setVideoLayout({ label: 'Focused', class: 'focused', icon: 'person-video2' });
}

// If nobody else is in the call, persist a flux://call_started link so the
// AD4M notification trigger fires a push notification for the neighbourhood.
// Only the initiator emits this — late joiners don't, to avoid duplicate pushes.
// The link is cleaned up in leaveRoom() to prevent stale triggers.
if (agentsInCall.value.length === 0 && callRoute.value.channelId) {
const perspective = toRaw(communityService.value?.perspective);
const channelUrl = restoreChannelPrefix(callRoute.value.channelId);
if (perspective) {
perspective
.add(new Link({ source: channelUrl, predicate: CALL_STARTED, target: channelUrl }))
.then(() => {
persistedCallStartedLink.value = { source: channelUrl, target: channelUrl };
})
.catch((error) => console.error('Failed to persist call_started link:', error));
}
}
Comment on lines +633 to +648

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Race between perspective.add(...).then(...) and leaveRoom() can leak persisted links.

The tracking refs are only populated in the .then() callback after perspective.add() resolves. If leaveRoom() runs between the link being persisted on the host and the .then() callback updating persistedCallStartedLink / persistedInviteLinks, cleanupCallStartedLink() / cleanupInviteLinks() will see null / empty array and skip removal, orphaning the link.

For CALL_STARTED this is especially undesirable — a leftover link can re-trigger flux://call_started push notifications on subsequent notification runtime evaluations. The same issue applies to sendCallInvite at lines 732-738.

Consider pre-recording the intended link before add() and clearing it in a .catch() on failure (or tracking a pending set), so cleanup is safe regardless of callback timing:

🛡️ Suggested pattern
         if (perspective) {
-          perspective
-            .add(new Link({ source: channelUrl, predicate: CALL_STARTED, target: channelUrl }))
-            .then(() => {
-              persistedCallStartedLink.value = { source: channelUrl, target: channelUrl };
-            })
-            .catch((error) => console.error('Failed to persist call_started link:', error));
+          persistedCallStartedLink.value = { source: channelUrl, target: channelUrl };
+          perspective
+            .add(new Link({ source: channelUrl, predicate: CALL_STARTED, target: channelUrl }))
+            .catch((error) => {
+              persistedCallStartedLink.value = null;
+              console.error('Failed to persist call_started link:', error);
+            });
         }

Apply the same shape to sendCallInvite (push the tracking entry before add(), remove on .catch).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// If nobody else is in the call, persist a flux://call_started link so the
// AD4M notification trigger fires a push notification for the neighbourhood.
// Only the initiator emits this — late joiners don't, to avoid duplicate pushes.
// The link is cleaned up in leaveRoom() to prevent stale triggers.
if (agentsInCall.value.length === 0 && callRoute.value.channelId) {
const perspective = communityService.value?.perspective;
const channelUrl = restoreChannelPrefix(callRoute.value.channelId);
if (perspective) {
perspective
.add(new Link({ source: channelUrl, predicate: CALL_STARTED, target: channelUrl }))
.then(() => {
persistedCallStartedLink.value = { source: channelUrl, target: channelUrl };
})
.catch((error) => console.error('Failed to persist call_started link:', error));
}
}
// If nobody else is in the call, persist a flux://call_started link so the
// AD4M notification trigger fires a push notification for the neighbourhood.
// Only the initiator emits this — late joiners don't, to avoid duplicate pushes.
// The link is cleaned up in leaveRoom() to prevent stale triggers.
if (agentsInCall.value.length === 0 && callRoute.value.channelId) {
const perspective = communityService.value?.perspective;
const channelUrl = restoreChannelPrefix(callRoute.value.channelId);
if (perspective) {
persistedCallStartedLink.value = { source: channelUrl, target: channelUrl };
perspective
.add(new Link({ source: channelUrl, predicate: CALL_STARTED, target: channelUrl }))
.catch((error) => {
persistedCallStartedLink.value = null;
console.error('Failed to persist call_started link:', error);
});
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/stores/webrtcStore.ts` around lines 633 - 648, The persisted link can
be orphaned because persistedCallStartedLink (and persistedInviteLinks) are only
set in the perspective.add(...).then(...) callback, so if
leaveRoom()/cleanupCallStartedLink() runs before the promise resolves the
cleanup skips removal; to fix, pre-record the intended link entry before calling
perspective.add (i.e. set persistedCallStartedLink = { ... } or push into
persistedInviteLinks immediately), then call perspective.add(...).catch(error =>
{ clear the pre-recorded entry / remove from pending set and log the error });
apply the same pattern in sendCallInvite (set the invite tracking entry before
perspective.add() and remove it on .catch()) so
cleanupCallStartedLink()/cleanupInviteLinks() can reliably find and remove
persisted links regardless of timing.


inCall.value = true;
} catch (error) {
console.error('Error joining call:', error);
Expand All @@ -642,6 +661,10 @@ export const useWebrtcStore = defineStore(
// Signal all peers that we're leaving the call
signalPeers(WEBRTC_LEAVING_CALL);

// Clean up persisted call invite links to prevent stale notifications
cleanupInviteLinks();
cleanupCallStartedLink();
Comment on lines +664 to +666

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Await removals before dropping cleanup state.

cleanupInviteLinks() / cleanupCallStartedLink() clear local tracking immediately after starting perspective.remove(...). If removal fails, the link remains persisted but can no longer be retried, causing stale notifications.

Suggested cleanup shape
-        cleanupInviteLinks();
-        cleanupCallStartedLink();
+        await cleanupInviteLinks();
+        await cleanupCallStartedLink();
-    function cleanupInviteLinks(): void {
+    async function cleanupInviteLinks(): Promise<void> {
       const perspective = communityService.value?.perspective;
       if (!perspective || !persistedInviteLinks.value.length) return;
 
-      for (const invite of persistedInviteLinks.value) {
-        perspective
-          .remove(new Link({ source: invite.source, predicate: CALL_INVITE, target: invite.target }))
-          .catch((error) => console.error('Failed to remove call invite link:', error));
-      }
-      persistedInviteLinks.value = [];
+      const invites = [...persistedInviteLinks.value];
+      const failed: Array<{ source: string; target: string }> = [];
+
+      await Promise.all(
+        invites.map(async (invite) => {
+          try {
+            await perspective.remove(new Link({ source: invite.source, predicate: CALL_INVITE, target: invite.target }));
+          } catch (error) {
+            failed.push(invite);
+            console.error('Failed to remove call invite link:', error);
+          }
+        }),
+      );
+
+      persistedInviteLinks.value = failed;
     }
 
-    function cleanupCallStartedLink(): void {
+    async function cleanupCallStartedLink(): Promise<void> {
       const perspective = communityService.value?.perspective;
       const link = persistedCallStartedLink.value;
       if (!perspective || !link) return;
 
-      perspective
-        .remove(new Link({ source: link.source, predicate: CALL_STARTED, target: link.target }))
-        .catch((error) => console.error('Failed to remove call_started link:', error));
-      persistedCallStartedLink.value = null;
+      try {
+        await perspective.remove(new Link({ source: link.source, predicate: CALL_STARTED, target: link.target }));
+        persistedCallStartedLink.value = null;
+      } catch (error) {
+        console.error('Failed to remove call_started link:', error);
+      }
     }

Also applies to: 761-781

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/stores/webrtcStore.ts` around lines 670 - 672, The cleanup functions
cleanupInviteLinks and cleanupCallStartedLink currently clear local tracking
immediately after calling perspective.remove(...), so if remove fails the
persisted link remains but local state was dropped; modify these functions to be
async and await the perspective.remove call (or await a Promise.all if removing
multiple links), only clear the local tracking state after the remove promise
resolves successfully, and on error log (processLogger/errorLogger) and keep the
tracking state so the cleanup can be retried; apply the same change to the
analogous cleanup block at the other location mentioned (around the other
761-781 section).


// Close all peer connections
peerConnections.value.forEach((_, did) => cleanupPeerConnection(did));

Expand Down Expand Up @@ -689,6 +712,61 @@ export const useWebrtcStore = defineStore(
}
}

// Track persisted invite links so we can clean them up when leaving the call
const persistedInviteLinks = ref<Array<{ source: string; target: string }>>([]);
const persistedCallStartedLink = ref<{ source: string; target: string } | null>(null);

function sendCallInvite(dids: string[]): void {
if (!signallingService.value || !callRoute.value.channelId) return;
const channelUrl = restoreChannelPrefix(callRoute.value.channelId);
const perspective = toRaw(communityService.value?.perspective);

for (const did of dids) {
// Broadcast signal for real-time notification (when recipient is online)
signallingService.value.sendSignal({
source: channelUrl,
predicate: CALL_INVITE,
target: did,
});

// Persist link for AD4M push notification trigger (when recipient is offline)
if (perspective) {
perspective
.add(new Link({ source: channelUrl, predicate: CALL_INVITE, target: did }))
.then(() => persistedInviteLinks.value.push({ source: channelUrl, target: did }))
.catch((error) => console.error('Failed to persist call invite link:', error));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const count = dids.length;
appStore.showSuccessToast({
message: `Call invite sent to ${count} member${count > 1 ? 's' : ''}`,
});
}

function cleanupInviteLinks(): void {
const perspective = toRaw(communityService.value?.perspective);
if (!perspective || !persistedInviteLinks.value.length) return;

for (const invite of persistedInviteLinks.value) {
perspective
.remove(new Link({ source: invite.source, predicate: CALL_INVITE, target: invite.target }))
.catch((error) => console.error('Failed to remove call invite link:', error));
}
persistedInviteLinks.value = [];
}

function cleanupCallStartedLink(): void {
const perspective = toRaw(communityService.value?.perspective);
const link = persistedCallStartedLink.value;
if (!perspective || !link) return;

perspective
.remove(new Link({ source: link.source, predicate: CALL_STARTED, target: link.target }))
.catch((error) => console.error('Failed to remove call_started link:', error));
persistedCallStartedLink.value = null;
}

// Close the call window on route param changes if not in a call or a channel
watch(
() => route.params,
Expand Down Expand Up @@ -812,6 +890,7 @@ export const useWebrtcStore = defineStore(
signalAgentsInCall,
displayEmoji,
copyCallLink,
sendCallInvite,
};
},
{ persist: false },
Expand Down
Loading
Loading