diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 3ffd679e2..6de7c2d2e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -99,6 +99,9 @@ jobs: pkg.pnpm.overrides = pkg.pnpm.overrides || {}; pkg.pnpm.overrides['@coasys/ad4m'] = 'file:./ad4m/core'; pkg.pnpm.overrides['@coasys/ad4m-connect'] = 'file:./ad4m/connect'; + pkg.pnpm.overrides['@coasys/hooks-helpers'] = 'file:./ad4m/ad4m-hooks/helpers'; + pkg.pnpm.overrides['@coasys/ad4m-react-hooks'] = 'file:./ad4m/ad4m-hooks/react'; + pkg.pnpm.overrides['@coasys/ad4m-vue-hooks'] = 'file:./ad4m/ad4m-hooks/vue'; require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n'); " @@ -111,8 +114,15 @@ jobs: rm -rf app/node_modules/.vite .turbo node_modules/.cache find . -name '.turbo' -type d -not -path './ad4m/*' -not -path './node_modules/*' -exec rm -rf {} + 2>/dev/null || true + - name: Lint + run: pnpm lint + - name: Build run: NODE_OPTIONS='--max-old-space-size=4096' pnpm build + - name: Type check (informational) + continue-on-error: true + run: pnpm typecheck + - name: Test run: pnpm test --filter @coasys/flux-api diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 77d647283..2028c9b8d 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -85,14 +85,24 @@ jobs: pkg.pnpm.overrides = pkg.pnpm.overrides || {}; pkg.pnpm.overrides['@coasys/ad4m'] = 'file:./ad4m/core'; pkg.pnpm.overrides['@coasys/ad4m-connect'] = 'file:./ad4m/connect'; + pkg.pnpm.overrides['@coasys/hooks-helpers'] = 'file:./ad4m/ad4m-hooks/helpers'; + pkg.pnpm.overrides['@coasys/ad4m-react-hooks'] = 'file:./ad4m/ad4m-hooks/react'; + pkg.pnpm.overrides['@coasys/ad4m-vue-hooks'] = 'file:./ad4m/ad4m-hooks/vue'; require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n'); " - name: Install dependencies run: pnpm install --no-frozen-lockfile + - name: Lint + run: pnpm lint + - name: Build run: NODE_OPTIONS='--max-old-space-size=4096' pnpm build + - name: Type check (informational) + continue-on-error: true + run: pnpm typecheck + - name: Test run: pnpm test --filter @coasys/flux-api diff --git a/app/.eslintignore b/app/.eslintignore index 220860b2d..da4117524 100644 --- a/app/.eslintignore +++ b/app/.eslintignore @@ -1,3 +1,4 @@ ../ad4m ../ad4m-types -vue.config.js \ No newline at end of file +vue.config.js +src/views/signup/perlin.js \ No newline at end of file diff --git a/app/.eslintrc.js b/app/.eslintrc.js index e7adcc7c1..52f47a287 100644 --- a/app/.eslintrc.js +++ b/app/.eslintrc.js @@ -3,7 +3,7 @@ module.exports = { env: { node: true, }, - extends: ['plugin:vue/vue3-essential', 'eslint:recommended', '@vue/typescript/recommended', '@vue/prettier'], + extends: ['plugin:vue/vue3-essential', 'eslint:recommended', '@vue/typescript/recommended'], parserOptions: { ecmaVersion: 2020, }, diff --git a/app/package.json b/app/package.json index c095e4282..1506b473a 100644 --- a/app/package.json +++ b/app/package.json @@ -16,6 +16,7 @@ "build": "NODE_OPTIONS='--max-old-space-size=4096' vite build", "preview": "vite preview", "lint": "eslint src/**/*.ts src/**/*.js src/**/*.vue", + "typecheck": "vue-tsc --noEmit", "test": "jest --env=jsdom" }, "main": "background.js", @@ -95,6 +96,7 @@ "vite-plugin-babel-compiler": "^0.3.0", "vite-plugin-pwa": "^0.14.7", "vite-plugin-worker": "^1.0.5", - "vue-devtools": "^5.1.4" + "vue-devtools": "^5.1.4", + "vue-tsc": "^3.2.7" } } \ No newline at end of file diff --git a/app/src/components/conversation/timeline/TimelineBlock.vue b/app/src/components/conversation/timeline/TimelineBlock.vue index 5929cdfb3..c344611b4 100644 --- a/app/src/components/conversation/timeline/TimelineBlock.vue +++ b/app/src/components/conversation/timeline/TimelineBlock.vue @@ -109,7 +109,6 @@ :match-indexes="matchIndexes" :set-match-indexes="setMatchIndexes" :zoom="zoom" - :refresh-trigger="refreshTrigger" :selected-topic-id="selectedTopicId" :selected-item-id="selectedItemId" :set-selected-item-id="setSelectedItemId" @@ -197,7 +196,6 @@ interface Props { matchIndexes?: MatchIndexes; setMatchIndexes?: (indexes: MatchIndexes) => void; zoom?: GroupingOption; - refreshTrigger?: number; selectedItemId?: string; setSelectedItemId?: (id: string | null) => void; search?: (type: SearchType, itemId: string, topic?: SynergyTopic) => void; @@ -411,9 +409,9 @@ function onGroupClick() { } } -// Get stats on first load and whenever refresh triggered if last child +// Get stats on first load and whenever data changes if last child watch( - () => props.refreshTrigger, + () => props.data, () => { if (firstLoad.value || props.lastChild) { firstLoad.value = false; @@ -424,9 +422,9 @@ watch( { immediate: true }, ); -// Get data when expanding children or refresh triggered & children expanded +// Get data when expanding children or data changes while children expanded watch( - [() => showChildren.value, () => props.refreshTrigger], + [() => showChildren.value, () => props.data], () => { // False on first load. Updated when zoom useEffect below fires and later when children are expanded by user if (showChildren.value) { diff --git a/app/src/components/conversation/timeline/TimelineColumn.vue b/app/src/components/conversation/timeline/TimelineColumn.vue index be2718d79..8b14c701b 100644 --- a/app/src/components/conversation/timeline/TimelineColumn.vue +++ b/app/src/components/conversation/timeline/TimelineColumn.vue @@ -70,7 +70,6 @@ :data="conversation" :timeline-index="0" :zoom="zoom" - :refresh-trigger="refreshTrigger" :selected-topic-id="selectedTopicId" :selected-item-id="selectedItemId" :set-selected-item-id="setSelectedItemId" @@ -131,11 +130,12 @@ import { getCachedAgentProfile } from '@/utils/userProfileCache'; import { llmProcessingSteps, useAiStore, useAppStore } from '@/stores'; import { closeMenu } from '@/utils/helperFunctions'; import { restoreChannelPrefix, stripNeighbourhoodPrefix } from '@/utils/routeUtils'; -import { Channel, Conversation } from '@coasys/flux-api'; +import { Channel, ChannelSummary, Conversation } from '@coasys/flux-api'; +import { useLiveQuery } from '@coasys/ad4m-vue-hooks'; import { ProcessingState } from '@coasys/flux-types'; import { GroupingOption, groupingOptions, SearchType, SynergyGroup, SynergyItem } from '@coasys/flux-utils'; import { storeToRefs } from 'pinia'; -import { onMounted, onUnmounted, ref, watch } from 'vue'; +import { onUnmounted, ref, watch, watchEffect } from 'vue'; import { useRoute } from 'vue-router'; interface Props { @@ -145,8 +145,6 @@ interface Props { defineProps(); -const LINK_ADDED_TIMEOUT = 2000; - const route = useRoute(); const appStore = useAppStore(); const aiStore = useAiStore(); @@ -158,19 +156,74 @@ const { signallingService, perspective, getRecentConversations, getPinnedConvers const channelUrl = restoreChannelPrefix(route.params.channelId as string); +// Scoped live query — only fires when conversations under this channel change. +// This subscription only fires when Conversation instances under this channel change, +// not on every link change in the entire perspective. +const { data: conversationInstances } = useLiveQuery(Conversation, perspective, { + parent: { model: Channel, id: channelUrl }, +}); + const conversations = ref([]); const unprocessedItems = ref([]); const processingState = ref(null); const selectedItemId = ref(''); const zoom = ref(groupingOptions[0]); -const refreshTrigger = ref(0); -const gettingData = ref(false); -const linkAddedTimeout = ref(null); -const linkUpdatesQueued = ref(null); const loading = ref(true); const exporting = ref(false); const exportingFlat = ref(false); +// --- Unprocessed items refresh --- +// The conversation subscription (useLiveQuery) only fires when Conversation entities +// change (name, summary, subgroups). New messages are children of the Channel, not +// Conversation changes. We use a targeted SPARQL subscription that only fires when +// THIS channel's children change, rather than addListener('link-added') which fires +// on every link in the entire perspective. +let unprocessedItemsTimer: ReturnType | null = null; +let channelItemsSub: { dispose: () => void } | null = null; +let isUnmounted = false; + +async function refreshUnprocessedItems() { + try { + const channel = new Channel(perspective, channelUrl); + unprocessedItems.value = await channel.unprocessedItems(); + } catch (error) { + console.error('Error fetching unprocessed items:', error); + } +} + +function scheduleUnprocessedItemsRefresh() { + // Debounce: batch commits can trigger multiple subscription updates in succession + if (unprocessedItemsTimer) clearTimeout(unprocessedItemsTimer); + unprocessedItemsTimer = setTimeout(refreshUnprocessedItems, 500); +} + +// SPARQL subscription: fires only when items are added/removed from THIS channel. +// The query tracks all ad4m://has_child links from this channel — when the result +// set changes (new message, post, or task added), the subscription callback fires. +(async () => { + try { + const sub = await perspective.subscribeQuery(` + SELECT ?id WHERE { <${channelUrl}> ?id . } + `); + if (isUnmounted) { + sub.dispose(); + return; + } + channelItemsSub = sub; + sub.onResult(() => { + scheduleUnprocessedItemsRefresh(); + }); + } catch (error) { + console.error('Failed to subscribe to channel items:', error); + } +})(); + +onUnmounted(() => { + isUnmounted = true; + if (unprocessedItemsTimer) clearTimeout(unprocessedItemsTimer); + channelItemsSub?.dispose(); +}); + function stripHtml(html: string): string { return html?.replace(/<[^>]*>/g, '')?.trim() || ''; } @@ -286,174 +339,58 @@ async function exportTranscript() { } } -async function getConversations() { - const channel = await Channel.findOne(perspective, { where: { id: channelUrl }, include: { conversations: true } }); - return channel?.conversationsData() ?? []; -} - -async function getUnprocessedItems() { - const channel = new Channel(perspective, channelUrl); - return await channel.unprocessedItems(); -} - -// Predicates that indicate conversation metadata changes (require full refresh) -const CONVERSATION_META_PREDICATES = ['flux://has_name', 'flux://has_summary', 'flux://has_child']; -// Predicates that indicate new messages (only need unprocessed items refresh) -const MESSAGE_PREDICATES = ['flux://has_expression', 'ad4m://has_child']; - -function isConversationMetaPredicate(predicate: string | undefined): boolean { - return !!predicate && CONVERSATION_META_PREDICATES.some(p => predicate.includes(p)); -} - -function isMessagePredicate(predicate: string | undefined): boolean { - // If predicate is unknown, treat as message (safe default — just refreshes unprocessed) - return !predicate || MESSAGE_PREDICATES.some(p => predicate.includes(p)); -} - -async function getData(firstRun?: boolean): Promise { - return getDataFull(firstRun); -} - -async function getDataFull(firstRun?: boolean): Promise { - if (gettingData.value) return; - - gettingData.value = true; +// Reactive conversations — derived directly from the scoped useLiveQuery subscription. +// No imperative fetch needed; conversationInstances updates trigger a synchronous re-map. +watchEffect(() => { + const instances = conversationInstances.value; + conversations.value = (instances || []).map(conv => ({ + id: conv.id, + name: conv.conversationName || '', + summary: conv.summary || '', + timestamp: conv.createdAt || '', + })); + if (loading.value) loading.value = false; +}); - try { - const [newConversations, newUnprocessedItems] = await Promise.all([getConversations(), getUnprocessedItems()]); +// Unprocessed items — re-fetched when conversation subscription fires +// (e.g. after processing updates conversation name/summary/subgroups). +// Also triggered by the link-added listener above for new messages. +watchEffect(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _ = conversationInstances.value; + await refreshUnprocessedItems(); +}); - // Update sidebar items if the conversations name has changed - if (conversations.value[0] && newConversations[0] && conversations.value[0].name !== newConversations[0].name) { +// Sidebar refresh when the most-recent conversation's name changes +watch( + () => conversations.value[0]?.name, + (newName, oldName) => { + if (oldName && newName !== oldName) { getPinnedConversations(); getRecentConversations(); getChannelsWithConversations(); } - - // Update state - conversations.value = newConversations; - unprocessedItems.value = newUnprocessedItems; - gettingData.value = false; - if (firstRun) loading.value = false; - - // Trigger a refresh in child components - refreshTrigger.value = refreshTrigger.value + 1; - - // If this is not the first run and AI is enabled, check if we should process tasks - if (firstRun || !aiEnabled.value) return; - const shouldProcess = await aiStore.checkIfWeShouldProcessTask(newUnprocessedItems, signallingService, channelUrl); - if (shouldProcess) { - const channel = new Channel(perspective, channelUrl); - aiStore.addTasksToProcessingQueue([{ communityId: perspective.sharedUrl!, channel }]); - } - } catch (error) { - console.error('Error fetching conversations or unprocessed items:', error); - gettingData.value = false; } -} - -async function getDataIncremental(): Promise { - if (gettingData.value) return; - - gettingData.value = true; +); +// AI task check — runs when unprocessed items change (skips initial empty state) +watch(unprocessedItems, async (items) => { + if (!aiEnabled.value || !items.length) return; try { - // Only refresh unprocessed items — conversations haven't changed - const newUnprocessedItems = await getUnprocessedItems(); - unprocessedItems.value = newUnprocessedItems; - gettingData.value = false; - - // Trigger a refresh in child components - refreshTrigger.value = refreshTrigger.value + 1; - - // Check if we should process tasks - if (!aiEnabled.value) return; - const shouldProcess = await aiStore.checkIfWeShouldProcessTask(newUnprocessedItems, signallingService, channelUrl); + const shouldProcess = await aiStore.checkIfWeShouldProcessTask(items, signallingService, channelUrl); if (shouldProcess) { - const channel = new Channel(perspective, channelUrl); + const channel = new ChannelSummary(perspective, channelUrl); aiStore.addTasksToProcessingQueue([{ communityId: perspective.sharedUrl!, channel }]); } } catch (error) { - console.error('Error fetching unprocessed items:', error); - gettingData.value = false; - } -} - -async function refreshConversations(): Promise { - try { - const newConversations = await getConversations(); - if (conversations.value[0] && newConversations[0] && conversations.value[0].name !== newConversations[0].name) { - getPinnedConversations(); - getRecentConversations(); - getChannelsWithConversations(); - } - conversations.value = newConversations; - refreshTrigger.value = refreshTrigger.value + 1; - } catch (error) { - console.error('Error refreshing conversations:', error); + console.error('Error checking AI tasks:', error); } -} - -// TODO: Remove this if we can achieve the same with subscriptions. Currently inspects link predicates. -function handleLinkAdded(link?: any) { - const predicate = link?.data?.predicate; - - // Determine which refresh path to take - const needsFullRefresh = isConversationMetaPredicate(predicate); - const refreshFn = needsFullRefresh ? getDataFull : getDataIncremental; - - // Debounced with LINK_ADDED_TIMEOUT to avoid concurrent data fetches - - // If in cooldown period, just mark that we've seen a new event and exit - // If any event during cooldown needs full refresh, upgrade the queued refresh - if (linkAddedTimeout.value) { - linkUpdatesQueued.value = true; - if (needsFullRefresh) (linkUpdatesQueued as any)._needsFull = true; - return null; - } - - // Otherwise get new data immediately - refreshFn(); - linkUpdatesQueued.value = false; - (linkUpdatesQueued as any)._needsFull = false; - - // Set cooldown period with callback that checks for queued updates - linkAddedTimeout.value = setTimeout(() => { - linkAddedTimeout.value = null; - - // If new events came in during cooldown, process them now - if (linkUpdatesQueued.value) { - const fn = (linkUpdatesQueued as any)._needsFull ? getDataFull : getDataIncremental; - fn(); - linkUpdatesQueued.value = false; - (linkUpdatesQueued as any)._needsFull = false; - } - }, LINK_ADDED_TIMEOUT); - - return null; -} +}); function setSelectedItemId(id: string | null) { selectedItemId.value = id || ''; } -onMounted(() => { - // Wait until appstore & signallingService are available before initializing - if (signallingService) { - getData(true); - - // Listen for link-added events from the perspective - perspective.addListener('link-added', handleLinkAdded); - } -}); - -onUnmounted(() => { - // Remove the link-added listener when the component is unmounted - if (signallingService) perspective.removeListener('link-added', handleLinkAdded); - - // Clear timeouts - if (linkAddedTimeout.value) clearTimeout(linkAddedTimeout.value); -}); - watch( signallingService.agents.value, (newAgents) => { diff --git a/app/src/components/conversation/timeline/__tests__/timeline-subscription.test.ts b/app/src/components/conversation/timeline/__tests__/timeline-subscription.test.ts new file mode 100644 index 000000000..eea8f312a --- /dev/null +++ b/app/src/components/conversation/timeline/__tests__/timeline-subscription.test.ts @@ -0,0 +1,251 @@ +/** + * Tests for the unprocessed-items refresh logic in TimelineColumn.vue. + * + * The key bug: the watchEffect for unprocessedItems depended solely on + * conversationInstances (useLiveQuery subscription). New messages are children + * of the Channel, not Conversation changes, so the subscription never fired + * for new messages. + * + * The fix uses a targeted SPARQL subscription via perspective.subscribeQuery() + * that only fires when THIS channel's ad4m://has_child links change, rather + * than addListener('link-added') which fires for every link in the perspective. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Extracted logic under test +// --------------------------------------------------------------------------- + +/** + * Simulates the debounced refresh logic from TimelineColumn.vue. + * The SPARQL subscription fires when the channel's children change; + * the debounce prevents rapid-fire refreshes from batch commits. + */ +function createDebouncedRefreshHandler() { + let timer: ReturnType | null = null; + let refreshCallCount = 0; + const refreshTimestamps: number[] = []; + + async function refreshUnprocessedItems() { + refreshCallCount++; + refreshTimestamps.push(Date.now()); + } + + function scheduleRefresh() { + if (timer) clearTimeout(timer); + timer = setTimeout(refreshUnprocessedItems, 50); // Short timeout for testing + } + + function cleanup() { + if (timer) clearTimeout(timer); + } + + return { + scheduleRefresh, + cleanup, + get refreshCallCount() { return refreshCallCount; }, + get refreshTimestamps() { return refreshTimestamps; }, + }; +} + +// --------------------------------------------------------------------------- +// Tests: SPARQL subscription query correctness +// --------------------------------------------------------------------------- + +describe('channel items SPARQL subscription', () => { + it('query targets only the specific channel URL', () => { + const channelUrl = 'channel://test-channel-1'; + // This mirrors the query constructed in TimelineColumn.vue + const query = `SELECT ?id WHERE { <${channelUrl}> ?id . }`; + + expect(query).toContain(channelUrl); + expect(query).toContain('ad4m://has_child'); + // Should NOT contain wildcards that would match other channels + expect(query).not.toContain('?source'); + }); + + it('query uses ad4m://has_child predicate (not flux://has_item)', () => { + const channelUrl = 'channel://test-channel-1'; + const query = `SELECT ?id WHERE { <${channelUrl}> ?id . }`; + + // Channel items use ad4m://has_child, NOT flux://has_item (which is subgroup→item) + expect(query).toContain('ad4m://has_child'); + expect(query).not.toContain('flux://has_item'); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Subscription lifecycle +// --------------------------------------------------------------------------- + +describe('channel items subscription lifecycle', () => { + it('creates a mock subscription with onResult and dispose', async () => { + // Simulates what perspective.subscribeQuery() returns + const callbacks = new Set<() => void>(); + const mockSub = { + result: [], + onResult: (cb: () => void) => { callbacks.add(cb); return () => callbacks.delete(cb); }, + dispose: vi.fn(), + }; + + // Register callback (mirrors TimelineColumn setup) + const handler = createDebouncedRefreshHandler(); + mockSub.onResult(() => handler.scheduleRefresh()); + + // Simulate a subscription update (new item added) + for (const cb of callbacks) cb(); + + // Wait for debounce + await new Promise(r => setTimeout(r, 100)); + expect(handler.refreshCallCount).toBe(1); + + // Cleanup + handler.cleanup(); + mockSub.dispose(); + expect(mockSub.dispose).toHaveBeenCalledTimes(1); + }); + + it('dispose prevents further callbacks from firing', async () => { + const callbacks = new Set<() => void>(); + const mockSub = { + onResult: (cb: () => void) => { + callbacks.add(cb); + return () => callbacks.delete(cb); + }, + dispose: () => callbacks.clear(), + }; + + const handler = createDebouncedRefreshHandler(); + mockSub.onResult(() => handler.scheduleRefresh()); + + // First update — should trigger refresh + for (const cb of callbacks) cb(); + await new Promise(r => setTimeout(r, 100)); + expect(handler.refreshCallCount).toBe(1); + + // Dispose subscription + mockSub.dispose(); + + // Second update — should NOT trigger refresh (callbacks cleared) + for (const cb of callbacks) cb(); + await new Promise(r => setTimeout(r, 100)); + expect(handler.refreshCallCount).toBe(1); // Still 1 + + handler.cleanup(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Debouncing +// --------------------------------------------------------------------------- + +describe('unprocessed items refresh — debouncing', () => { + let handler: ReturnType; + + beforeEach(() => { + handler = createDebouncedRefreshHandler(); + }); + + afterEach(() => { + handler.cleanup(); + }); + + it('debounces rapid subscription updates into a single refresh', async () => { + // Simulate a batch commit triggering multiple subscription updates + handler.scheduleRefresh(); + handler.scheduleRefresh(); + handler.scheduleRefresh(); + + await new Promise((r) => setTimeout(r, 100)); + expect(handler.refreshCallCount).toBe(1); + }); + + it('fires separate refreshes for updates after debounce window', async () => { + handler.scheduleRefresh(); + + // Wait for debounce to complete, then trigger another + await new Promise((r) => setTimeout(r, 100)); + handler.scheduleRefresh(); + + await new Promise((r) => setTimeout(r, 100)); + expect(handler.refreshCallCount).toBe(2); + }); + + it('resets the debounce timer on each call', async () => { + handler.scheduleRefresh(); + + // Call again before debounce expires — should reset the timer + await new Promise((r) => setTimeout(r, 30)); + handler.scheduleRefresh(); + + // At 60ms from start: first timer (50ms) would have fired, but it was reset at 30ms + // So only the second timer (30ms + 50ms = 80ms) fires + await new Promise((r) => setTimeout(r, 30)); + expect(handler.refreshCallCount).toBe(0); // Not yet fired + + await new Promise((r) => setTimeout(r, 60)); + expect(handler.refreshCallCount).toBe(1); // Now fired + }); +}); + +// --------------------------------------------------------------------------- +// Tests: AI processing trigger chain +// --------------------------------------------------------------------------- + +describe('AI processing trigger chain', () => { + it('checkIfWeShouldProcessTask requires MIN_ITEMS + DELAY items', () => { + const MIN_ITEMS_TO_PROCESS = 5; + const PROCESSING_ITEMS_DELAY = 3; + const threshold = MIN_ITEMS_TO_PROCESS + PROCESSING_ITEMS_DELAY; + + expect(7).toBeLessThan(threshold); + expect(threshold).toBe(8); + expect(9).toBeGreaterThan(threshold); + }); + + it('items-to-process count subtracts DELAY from total', () => { + const MAX_ITEMS_TO_PROCESS = 20; + const PROCESSING_ITEMS_DELAY = 3; + + // Simulates the calculation in processesNextTask + const unprocessedCount = 10; + const numberOfItemsToProcess = Math.max( + 0, + Math.min(MAX_ITEMS_TO_PROCESS, unprocessedCount - PROCESSING_ITEMS_DELAY), + ); + expect(numberOfItemsToProcess).toBe(7); // 10 - 3 = 7 + + // Edge case: exactly at threshold + const atThreshold = 8; + const itemsAtThreshold = Math.max( + 0, + Math.min(MAX_ITEMS_TO_PROCESS, atThreshold - PROCESSING_ITEMS_DELAY), + ); + expect(itemsAtThreshold).toBe(5); // 8 - 3 = 5 + + // Edge case: below threshold (shouldn't reach processesNextTask, but test anyway) + const belowThreshold = 2; + const itemsBelow = Math.max( + 0, + Math.min(MAX_ITEMS_TO_PROCESS, belowThreshold - PROCESSING_ITEMS_DELAY), + ); + expect(itemsBelow).toBe(0); // max(0, 2-3) = 0 + }); + + it('authorship check requires at least one item by the current user', () => { + const myDid = 'did:test:me'; + const items = [ + { id: 'msg-1', author: 'did:test:alice' }, + { id: 'msg-2', author: 'did:test:bob' }, + ]; + + const weAuthored = items.some((item) => item.author === myDid); + expect(weAuthored).toBe(false); + + // Add one of our items + items.push({ id: 'msg-3', author: myDid }); + const weAuthoredNow = items.some((item) => item.author === myDid); + expect(weAuthoredNow).toBe(true); + }); +}); diff --git a/app/src/composables/useCommunityService.ts b/app/src/composables/useCommunityService.ts index 306c8b206..c86e773ad 100644 --- a/app/src/composables/useCommunityService.ts +++ b/app/src/composables/useCommunityService.ts @@ -6,6 +6,7 @@ import { useLiveQuery } from '@coasys/ad4m-vue-hooks'; import { App, Channel, + ChannelSummary, Community, Conversation, ConversationSubgroup, @@ -23,7 +24,7 @@ import { community as communityPredicates } from '@coasys/flux-constants'; const { CHANNEL } = communityPredicates; import { AgentData, Profile, SignallingService } from '@coasys/flux-types'; import { storeToRefs } from 'pinia'; -import { computed, ComputedRef, inject, InjectionKey, ref, Ref, watch } from 'vue'; +import { computed, ComputedRef, inject, InjectionKey, markRaw, ref, Ref, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { HEARTBEAT_INTERVAL, useSignallingService } from './useSignallingService'; @@ -38,7 +39,7 @@ export interface ChannelData { // Returned from computed — includes resolved model instances (ComputedRef does not apply deep unwrapping) export interface ChannelDataWithAgents { channelId: string; - channel?: Channel; + channel?: ChannelSummary; conversationId?: string; conversation?: Conversation; lastActivity?: string; @@ -50,13 +51,13 @@ export interface ChannelDataWithAgents { export interface CommunityService { perspective: PerspectiveProxy; neighbourhood: NeighbourhoodProxy; - signallingService: SignallingService; + signallingService: SignallingService | null; isSynced: Ref; isAuthor: ComputedRef; community: ComputedRef; members: Ref[]>; membersLoading: Ref; - allChannels: Ref; + allChannels: Ref; pinnedConversations: Ref; pinnedConversationsLoading: Ref; pinnedConversationsWithAgents: ComputedRef; @@ -78,7 +79,7 @@ export interface CommunityService { newSpaceChannelId: string, conversationName?: string, ) => Promise; - getParentChannel: (channelId: string) => Channel | undefined; + getParentChannel: (channelId: string) => ChannelSummary | undefined; getConversation: (channelId: string) => Conversation | undefined; cleanup: () => void; } @@ -95,7 +96,10 @@ export async function createCommunityService(): Promise { const { aiEnabled } = storeToRefs(aiStore); // Get the perspective and neighbourhood proxies - const maybePerspective = appStore.getPerspective(restoreNeighbourhoodPrefix(route.params.communityId as string)); + const communityIdParam = route.params.communityId as string; + // Try neighbourhood:// first, then private:// for local-only perspectives + const maybePerspective = appStore.getPerspective(restoreNeighbourhoodPrefix(communityIdParam)) + || appStore.getPerspective(`private://${communityIdParam}`); if (!maybePerspective) { const communityId = route.params.communityId as string; console.error(`Failed to get perspective for community: ${communityId}`); @@ -104,8 +108,10 @@ export async function createCommunityService(): Promise { ); } // Narrowed to PerspectiveProxy — TypeScript does not narrow through closures so we reassign explicitly - const perspective: PerspectiveProxy = maybePerspective; - const neighbourhood = perspective.getNeighbourhoodProxy(); + // markRaw prevents Vue from wrapping PerspectiveProxy in a reactive Proxy, which breaks + // TypeScript #private fields (WeakMap lookup fails when 'this' is a Proxy). + const perspective: PerspectiveProxy = markRaw(maybePerspective); + const neighbourhood = perspective.getNeighbourhoodProxy?.() || null; // Ensure all required SDNA is installed (sequential to avoid Rust concurrency issues) for (const Model of [ @@ -126,11 +132,14 @@ export async function createCommunityService(): Promise { } // Initialise the signalling service for the community - const signallingService = useSignallingService(neighbourhood); + const signallingService = neighbourhood ? useSignallingService(neighbourhood) : null; // Model subscriptions - const { data: communities } = useLiveQuery(Community, perspective); - const { data: allChannels } = useLiveQuery(Channel, perspective); + // Community query is perspective-scoped (typically one per perspective — low cost). + // Use ChannelSummary — lightweight model without @HasMany relations. + // Getters are skipped by default on collection queries (deepQuery inversion). + const { data: communities, loading: communitiesLoading, error: communitiesError } = useLiveQuery(Community, perspective); + const { data: allChannels, loading: channelsLoading, error: channelsError } = useLiveQuery(ChannelSummary, perspective); // Cache for conversation instances — populated during data fetching, looked up in computeds. // Plain Map (not reactive) is sufficient: updates always precede the ref changes that trigger re-computation. @@ -159,8 +168,8 @@ export async function createCommunityService(): Promise { ...data, channel: allChannels.value.find((c) => c.id === data.channelId), conversation: data.conversationId ? conversationCache.get(data.conversationId) : undefined, - agentsInChannel: signallingService.getAgentsInChannel(data.channelId).value, - agentsInCall: signallingService.getAgentsInCall(data.channelId).value, + agentsInChannel: signallingService?.getAgentsInChannel(data.channelId).value, + agentsInCall: signallingService?.getAgentsInCall(data.channelId).value, children: undefined, })); }); @@ -169,8 +178,8 @@ export async function createCommunityService(): Promise { ...data, channel: allChannels.value.find((c) => c.id === data.channelId), conversation: data.conversationId ? conversationCache.get(data.conversationId) : undefined, - agentsInChannel: signallingService.getAgentsInChannel(data.channelId).value, - agentsInCall: signallingService.getAgentsInCall(data.channelId).value, + agentsInChannel: signallingService?.getAgentsInChannel(data.channelId).value, + agentsInCall: signallingService?.getAgentsInCall(data.channelId).value, children: undefined, })); }); @@ -179,15 +188,15 @@ export async function createCommunityService(): Promise { ...data, channel: allChannels.value.find((c) => c.id === data.channelId), conversation: data.conversationId ? conversationCache.get(data.conversationId) : undefined, - agentsInChannel: signallingService.getAgentsInChannel(data.channelId).value, - agentsInCall: signallingService.getAgentsInCall(data.channelId).value, + agentsInChannel: signallingService?.getAgentsInChannel(data.channelId).value, + agentsInCall: signallingService?.getAgentsInCall(data.channelId).value, children: data.children?.map((child) => ({ ...child, channel: allChannels.value.find((c) => c.id === child.channelId), conversation: child.conversationId ? conversationCache.get(child.conversationId) : undefined, - agentsInChannel: signallingService.getAgentsInChannel(child.channelId).value, - agentsInCall: signallingService.getAgentsInCall(child.channelId).value, + agentsInChannel: signallingService?.getAgentsInChannel(child.channelId).value, + agentsInCall: signallingService?.getAgentsInCall(child.channelId).value, children: undefined, })) || [], })); @@ -231,15 +240,26 @@ export async function createCommunityService(): Promise { pinnedConversationsLoading.value = true; try { - // Loop through all the pinned channels and get the conversation data for each - pinnedConversations.value = await Promise.all( - pinnedChannels.value.map(async (channel: Channel) => { - await channel.get({ conversations: true }); - const conversation = channel.conversations[0]; - if (conversation) conversationCache.set(conversation.id, conversation); - return { channelId: channel.id, conversationId: conversation?.id }; + // Single SPARQL query — avoids iterative channel.get({ conversations: true }) + const results = await Channel.pinnedConversations(perspective); + + // Hydrate conversations so properties like conversationName are available + const newPinnedIds = results + .filter((r) => r.conversationId && !conversationCache.has(r.conversationId)) + .map((r) => r.conversationId!); + await Promise.all( + newPinnedIds.map(async (id) => { + const conv = new Conversation(perspective, id); + try { + await conv.get(); + } catch { + /* ignore */ + } + conversationCache.set(id, conv); }), ); + + pinnedConversations.value = results; } catch (error) { console.error('Error loading pinned conversations:', error); pinnedConversations.value = []; @@ -253,55 +273,28 @@ export async function createCommunityService(): Promise { recentConversationsLoading.value = true; try { - // Get the conversation data for each of the conversation channels and determine the last activity timestamp for each - const conversations = await Promise.all( - conversationChannels.value.map(async (channel: Channel) => { - await channel.get({ conversations: true }); - const conversation = channel.conversations[0]; - - if (!conversation) return null; - conversationCache.set(conversation.id, conversation); - - // If there are unprocessed items, use the latest unprocessed items timestamp - let lastActivity: string | null = null; - const unprocessedItems = await channel.unprocessedItems(); - if (unprocessedItems.length) { - const lastUnprocessedItem = unprocessedItems.sort( - (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), - )[0]; - lastActivity = lastUnprocessedItem.timestamp; - } else if (conversation.summary === 'Content will appear when the first items have been processed...') { - // If the conversation is an empty placeholder use the conversations timestamp - lastActivity = conversation.createdAt; - } else { - // If no subgroups exist, use the conversation timestamp - const subgroups = await conversation.subgroups(); - if (!subgroups.length) lastActivity = conversation.createdAt; - else { - // If no items exist in the last subgroup, use the subgroup timestamp - const lastSubgroup = subgroups[subgroups.length - 1]; - const items = await lastSubgroup.itemsData(); - if (!items.length) lastActivity = lastSubgroup.createdAt; - else { - // Finally, use the timestamp of the last item in the last subgroup - const lastItem = items.sort( - (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), - )[0]; - lastActivity = lastItem.timestamp; - } - } + // Single SPARQL query — avoids N×M×K iterative graph walk + // (was: for each channel → get conversations → unprocessedItems → subgroups → items) + const results = await Channel.recentConversations(perspective, 20); + + // Populate conversation cache — hydrate with .get() so properties like + // conversationName are available for display in the sidebar. + const newConvIds = results + .filter((r) => r.conversationId && !conversationCache.has(r.conversationId)) + .map((r) => r.conversationId!); + await Promise.all( + newConvIds.map(async (id) => { + const conv = new Conversation(perspective, id); + try { + await conv.get(); + } catch { + /* hydration failure — stub will lack properties but won't break rendering */ } - - return { channelId: channel.id, conversationId: conversation.id, lastActivity }; + conversationCache.set(id, conv); }), ); - // Sort conversations by last activity timestamp - const conversationsSortedByLastActivity = conversations - .filter((c) => c !== null) - .sort((a, b) => new Date(b.lastActivity!).getTime() - new Date(a.lastActivity!).getTime()); - - recentConversations.value = conversationsSortedByLastActivity as ChannelData[]; + recentConversations.value = results as ChannelData[]; } catch (error) { console.error('Error loading recent conversations:', error); recentConversations.value = []; @@ -315,29 +308,44 @@ export async function createCommunityService(): Promise { channelsWithConversationsLoading.value = true; try { - // Loop through all the space channels and get the conversations in each - channelsWithConversations.value = await Promise.all( - spaceChannels.value.map(async (channel: Channel) => { - // Get all nested conversation channels — linked via CHANNEL predicate (same as startNewConversation) + // Phase 1: Collect all child channel IDs per space channel (parallel link queries) + const channelChildMap = await Promise.all( + spaceChannels.value.map(async (channel) => { const links = await perspective.get(new LinkQuery({ source: channel.id, predicate: CHANNEL })); const childChannelIds = new Set(links.map((l) => l.data.target)); const nestedConversationChannels = allChannels.value.filter( (ch) => ch.isConversation && childChannelIds.has(ch.id), ); + return { channelId: channel.id, children: nestedConversationChannels }; + }), + ); - // Get the conversation data for each of the nested conversation channels - const conversations = await Promise.all( - nestedConversationChannels.map(async (childChannel: Channel) => { - await childChannel.get({ conversations: true }); - const conversation = childChannel.conversations[0]; - if (conversation) conversationCache.set(conversation.id, conversation); - return { channelId: childChannel.id, conversationId: conversation?.id }; - }), - ); + // Phase 2: Batch all conversation lookups into a single flat Promise.all + // instead of nested per-space-channel loops + const allConvChannels = channelChildMap.flatMap((entry) => + entry.children.map((ch) => ({ spaceChannelId: entry.channelId, childChannel: ch })), + ); - return { channelId: channel.id, children: conversations }; + const conversationResults = await Promise.all( + allConvChannels.map(async ({ childChannel }) => { + try { + const conversation = await Conversation.findOne(perspective, { + parent: { model: Channel, id: childChannel.id }, + }); + if (conversation) conversationCache.set(conversation.id, conversation); + return { channelId: childChannel.id, conversationId: conversation?.id }; + } catch { + return { channelId: childChannel.id }; + } }), ); + + // Phase 3: Re-group results by space channel + const convByChildId = new Map(conversationResults.map((r) => [r.channelId, r])); + channelsWithConversations.value = channelChildMap.map((entry) => ({ + channelId: entry.channelId, + children: entry.children.map((ch) => convByChildId.get(ch.id) || { channelId: ch.id }), + })); } catch (error) { console.error('Error loading channels with conversations:', error); channelsWithConversations.value = []; @@ -380,8 +388,8 @@ export async function createCommunityService(): Promise { await App.create(perspective, { name, description, icon, pkg }, { parent: { model: Channel, id: channel.id } }); - // Update the recent conversations - getRecentConversations(); + // Update the recent conversations — await so sidebar reflects the new entry before navigation + await getRecentConversations(); // Navigate to the new channel const communityId = route.params.communityId as string; @@ -449,7 +457,7 @@ export async function createCommunityService(): Promise { } } - function getParentChannel(channelId: string): Channel | undefined { + function getParentChannel(channelId: string): ChannelSummary | undefined { const parentData = channelsWithConversations.value.find((c) => c.children?.some((child) => child.channelId === channelId), ); @@ -463,7 +471,17 @@ export async function createCommunityService(): Promise { return conversationCache.get(data.conversationId); } - // Track channel participants automatically + // Initialize sync state listener + const syncStateListener = (state: PerspectiveState) => { + // @ts-ignore + isSynced.value = state === PerspectiveState.Synced || state === '"Synced"'; // Todo: state should be "SYNCED" not ""Synced"" + return null; + }; + perspective.addSyncStateChangeListener(syncStateListener); + + // Track channel participants automatically. + // Uses addListener because participant tracking needs link.author metadata, + // which isn't available from SPARQL subscription results. function handleParticipantTracking(link: any) { if (link.data.predicate !== CHANNEL) return null; if (!link.author) return null; @@ -472,9 +490,6 @@ export async function createCommunityService(): Promise { const channel = allChannels.value.find((c) => c.id === channelId); if (!channel) return null; - if (channel.participants && channel.participants.includes(link.author)) return null; - - // Add participant link perspective .addLinks([{ source: channelId, predicate: 'flux://has_participant', target: link.author }]) .catch((error) => { @@ -487,16 +502,6 @@ export async function createCommunityService(): Promise { return null; } - - // Initialize sync state listener - const syncStateListener = (state: PerspectiveState) => { - // @ts-ignore - isSynced.value = state === PerspectiveState.Synced || state === '"Synced"'; // Todo: state should be "SYNCED" not ""Synced"" - return null; - }; - perspective.addSyncStateChangeListener(syncStateListener); - - // Initialize participant tracking perspective.addListener('link-added', handleParticipantTracking); // Cleanup function to remove all listeners @@ -506,12 +511,12 @@ export async function createCommunityService(): Promise { getMembers(); - watch(pinnedChannelsSignature, getPinnedConversations); + watch(pinnedChannelsSignature, getPinnedConversations, { immediate: true }); watch(conversationChannelsSignature, () => { getRecentConversations(); getChannelsWithConversations(); - }); - watch(spaceChannels, getChannelsWithConversations); + }, { immediate: true }); + watch(spaceChannels, getChannelsWithConversations, { immediate: true }); // Find processing tasks in the community when the conversations first load watch(recentConversations, () => { diff --git a/app/src/composables/useSignallingService.ts b/app/src/composables/useSignallingService.ts index 2c827b3ce..cd03e875d 100644 --- a/app/src/composables/useSignallingService.ts +++ b/app/src/composables/useSignallingService.ts @@ -221,7 +221,7 @@ export function useSignallingService(neighbourhood: NeighbourhoodProxy): Signall signalHandlers.value.forEach((handler) => handler(signal)); } - function broadcastState(target: string = ''): void { + function broadcastState(target = ''): void { if (!signalling.value) return; // Only the leader tab broadcasts to the network to prevent duplicate heartbeats if (!tabCoordinator.isLeader.value) return; diff --git a/app/src/containers/Conversation.vue b/app/src/containers/Conversation.vue index b3ccdbd54..071ceffc0 100644 --- a/app/src/containers/Conversation.vue +++ b/app/src/containers/Conversation.vue @@ -251,7 +251,7 @@ async function findTopicMatches(itemId: string, topicId: string): Promise { name: child.conversation!.conversationName, summary: child.conversation!.summary, timestamp: child.conversation!.createdAt, - channelId: child.channel!.id, + channelId: stripChannelPrefix(child.channel!.id), })) as (SynergyGroup & { channelId: string })[]; }); function navigateToConversation(channelId: string) { router.push({ name: 'view', - params: { communityId: route.params.communityId, channelId, viewId: 'conversation' }, + params: { communityId: route.params.communityId, channelId: stripChannelPrefix(channelId), viewId: 'conversation' }, }); } diff --git a/app/src/router/index.ts b/app/src/router/index.ts index aa952e94a..082aa04f1 100644 --- a/app/src/router/index.ts +++ b/app/src/router/index.ts @@ -93,7 +93,8 @@ router.beforeEach(async (to, from, next) => { const communityId = to.params.communityId; if (communityId && to.name !== 'join-community') { const neighbourhoodUrl = restoreNeighbourhoodPrefix(communityId as string); - const isMember = appStore.myPerspectives.some((p) => p.sharedUrl === neighbourhoodUrl); + const rawId = (communityId as string).replace(/^private:\/\//, ''); + const isMember = appStore.myPerspectives.some((p) => p.sharedUrl === neighbourhoodUrl || p.uuid === communityId || p.uuid === rawId); if (!isMember) { next({ name: 'join-community', params: { communityId }, query: { redirect: to.fullPath } }); return; diff --git a/app/src/stores/aiStore.ts b/app/src/stores/aiStore.ts index d94af1ba1..11d96e385 100644 --- a/app/src/stores/aiStore.ts +++ b/app/src/stores/aiStore.ts @@ -3,7 +3,7 @@ import { useAppStore, useCommunityServiceStore, useRouteMemoryStore } from '@/st import { restoreNeighbourhoodPrefix, stripChannelPrefix } from '@/utils/routeUtils'; import { AIModelLoadingStatus, AITask } from '@coasys/ad4m'; import { Model } from '@coasys/ad4m/lib/src/ai/AIResolver'; -import { Channel } from '@coasys/flux-api'; +import { Channel, ChannelSummary } from '@coasys/flux-api'; import { ProcessingState, SignallingService } from '@coasys/flux-types'; import { SynergyItem } from '@coasys/flux-utils'; import { defineStore, storeToRefs } from 'pinia'; @@ -45,7 +45,7 @@ export const MAX_ITEMS_TO_PROCESS = 10; export const PROCESSING_ITEMS_DELAY = 3; export const PROCESSING_STALE_TIMEOUT = 5 * 60 * 1000; // 5 minutes -export type ProcessingQueueItem = { communityId: string; channel: Partial }; +export type ProcessingQueueItem = { communityId: string; channel: ChannelSummary }; export const useAiStore = defineStore( 'aiStore', @@ -198,13 +198,14 @@ export const useAiStore = defineStore( const tasks = await Promise.all( unref(communityService.recentConversationsWithAgents).map(async (conversationData) => { if (!conversationData.channel) return null; - const rawChannel = toRaw(conversationData.channel); - if (!rawChannel.id) return null; - const unprocessedItems = await rawChannel.unprocessedItems(); + const summaryChannel = toRaw(conversationData.channel); + if (!summaryChannel.id) return null; + const fullChannel = new Channel(communityService!.perspective, summaryChannel.id); + const unprocessedItems = await fullChannel.unprocessedItems(); const shouldProcess = await checkIfWeShouldProcessTask( unprocessedItems, communityService.signallingService, - rawChannel.id, + summaryChannel.id, ); return shouldProcess ? { communityId, channel: conversationData.channel } : null; }), @@ -243,8 +244,8 @@ export const useAiStore = defineStore( const { channelId, communityId } = currentRoute.value; // Current channel gets highest priority - if (a.channel.id! === channelId && b.channel.id! !== channelId) return -1; - if (b.channel.id! === channelId && a.channel.id! !== channelId) return 1; + if (a.channel?.id === channelId && b.channel?.id !== channelId) return -1; + if (b.channel?.id === channelId && a.channel?.id !== channelId) return 1; // Current community gets second priority if (a.communityId === communityId && b.communityId !== communityId) return -1; @@ -268,8 +269,9 @@ export const useAiStore = defineStore( } // Get the items to process from the channel - console.log('🤖 Checking for unprocessed items in channel:', await rawChannel); - const unprocessedItems = await rawChannel.unprocessedItems!(); + const fullChannel = new Channel(communityService.perspective, rawChannel.id!); + console.log('🤖 Checking for unprocessed items in channel:', rawChannel.id); + const unprocessedItems = await fullChannel.unprocessedItems(); const numberOfItemsToProcess = Math.max( 0, Math.min(MAX_ITEMS_TO_PROCESS, unprocessedItems.length - PROCESSING_ITEMS_DELAY), @@ -293,13 +295,13 @@ export const useAiStore = defineStore( console.log('🤖 LLM processing started'); // Create setProcessingState function to update processing state at each step - function setProcessingState(newState: Partial | null) { + const setProcessingState = (newState: Partial | null) => { // Update our app level processing state processingState.value = newState ? { ...processingState.value, ...newState } : null; // Update our processing state in the assosiated signalling service communityService!.signallingService.setProcessingState(newState); - } + }; // Set our initial processing state const itemIds = itemsToProcess.map((item) => item.id); diff --git a/app/src/stores/appStore.ts b/app/src/stores/appStore.ts index 548b16aab..2aee2e581 100644 --- a/app/src/stores/appStore.ts +++ b/app/src/stores/appStore.ts @@ -17,7 +17,7 @@ export const useAppStore = defineStore( const updateState = ref('not-available'); const toast = ref({ variant: undefined, message: '', open: false }); const notification = ref<{ globalNotification: boolean }>({ globalNotification: true }); - const myPerspectives = ref([]); + const myPerspectives = shallowRef([]); const myCommunities = ref>({}); // Todo: store this as an array instead? const communitiesLoaded = ref(false); const holochainRestarting = ref(false); @@ -92,14 +92,22 @@ export const useAppStore = defineStore( // Get all my perspectives myPerspectives.value = await ad4mClient.value.perspective.all(); - // Filter perspectives that have a neighbourhood and map to community entries + // Filter perspectives that have a neighbourhood (or a community entry_type) and map to community entries const communityEntries = await Promise.all( toRaw(myPerspectives.value) - .filter((perspective) => perspective.neighbourhood) .map(async (perspective) => { - const community = (await Community.findAll(perspective as PerspectiveProxy))[0]; - if (!community) return null; - return [perspective.sharedUrl, community] as const; + try { + // Ensure SDNA is installed before querying (needed for imported perspectives) + await (perspective as PerspectiveProxy).ensureSDNASubjectClass(Community); + const allCommunities = await Community.findAll(perspective as PerspectiveProxy); + const community = allCommunities[0]; + if (!community) return null; + const key = perspective.sharedUrl || `private://${perspective.uuid}`; + return [key, community] as const; + } catch (e) { + console.warn(`Failed to load community from perspective ${perspective.uuid}:`, e); + return null; + } }), ); @@ -133,9 +141,14 @@ export const useAppStore = defineStore( } function getPerspective(neighbourhoodUrl: string): PerspectiveProxy | undefined { - const perspective = myPerspectives.value.find((p) => p.sharedUrl === neighbourhoodUrl) as + // Support both neighbourhood:// URLs and private:// UUID lookups + let perspective = myPerspectives.value.find((p) => p.sharedUrl === neighbourhoodUrl) as | PerspectiveProxy | undefined; + if (!perspective && neighbourhoodUrl.startsWith('private://')) { + const uuid = neighbourhoodUrl.slice('private://'.length); + perspective = myPerspectives.value.find((p) => p.uuid === uuid) as PerspectiveProxy | undefined; + } return toRaw(perspective); } diff --git a/app/src/utils/registerMobileNotifications.ts b/app/src/utils/registerMobileNotifications.ts index e9c056ade..4ee0dd955 100644 --- a/app/src/utils/registerMobileNotifications.ts +++ b/app/src/utils/registerMobileNotifications.ts @@ -12,7 +12,7 @@ function notificationConfig(perspectiveIds: string[], webhookAuth: string, agent appUrl: window.location.origin, appIconPath: window.location.origin + '/icon.png', trigger: `SELECT ?source ?predicate ?target WHERE { - GRAPH ?g { ?source ?predicate ?target . } + ?source ?predicate ?target . FILTER(?predicate = ) FILTER(CONTAINS( LCASE(STR((?target))), @@ -71,8 +71,8 @@ export async function registerNotification(client: Ad4mClient) { }); } - let notifications = await client.runtime.notifications(); - let foundNotifications = notifications.filter( + const notifications = await client.runtime.notifications(); + const foundNotifications = notifications.filter( (n) => n.appName == APP_NAME && n.description == DESCRIPTION && diff --git a/app/src/utils/routeUtils.ts b/app/src/utils/routeUtils.ts index b7fcd4694..56f616521 100644 --- a/app/src/utils/routeUtils.ts +++ b/app/src/utils/routeUtils.ts @@ -6,11 +6,17 @@ // Strips neighbourhood URL prefix to get clean community ID export function stripNeighbourhoodPrefix(neighbourhoodUrl: string): string { const prefix = 'neighbourhood://'; - return neighbourhoodUrl.startsWith(prefix) ? neighbourhoodUrl.slice(prefix.length) : neighbourhoodUrl; + const privatePrefix = 'private://'; + if (neighbourhoodUrl.startsWith(prefix)) return neighbourhoodUrl.slice(prefix.length); + if (neighbourhoodUrl.startsWith(privatePrefix)) return neighbourhoodUrl.slice(privatePrefix.length); + return neighbourhoodUrl; } // Restores neighbourhood URL prefix from clean community ID export function restoreNeighbourhoodPrefix(communityId: string): string { + if (!communityId) return ''; + // If the communityId already has a protocol prefix, return as-is + if (communityId.includes('://')) return communityId; return `neighbourhood://${communityId}`; } diff --git a/app/src/views/main/MainView.vue b/app/src/views/main/MainView.vue index 6237ba9a8..b2c931a68 100644 --- a/app/src/views/main/MainView.vue +++ b/app/src/views/main/MainView.vue @@ -24,12 +24,10 @@ import AppLayout from '@/layout/AppLayout.vue'; import { useAppStore } from '@/stores'; import Modals from '@/views/main/modals/Modals.vue'; import Sidebar from '@/views/main/sidebar/Sidebar.vue'; -import { LinkExpression, Literal, PerspectiveProxy } from '@coasys/ad4m'; import { usePerspectives } from '@coasys/flux-vue'; import { ensureLLMTasks } from '@coasys/flux-api/src/conversation/LLMutils'; -import { EntryType } from '@coasys/flux-types'; import semver from 'semver'; -import { onMounted, onUnmounted, ref } from 'vue'; +import { onMounted } from 'vue'; import { useRoute } from 'vue-router'; import { dependencies } from '../../../package.json'; import { registerNotification } from '../../utils/registerMobileNotifications'; @@ -37,24 +35,7 @@ import { registerNotification } from '../../utils/registerMobileNotifications'; const route = useRoute(); const appStore = useAppStore(); -const { onLinkAdded } = usePerspectives(appStore.ad4mClient); -let cleanupLinkAdded: (() => void) | undefined; - -function gotNewMessage(p: PerspectiveProxy, link: LinkExpression) { - const routeChannelId = route.params.channelId; - const channelId = link.data.source; - const isCurrentChannel = routeChannelId === channelId; - if (isCurrentChannel) return; - - // TODO: Update channel to say it has a new message - const expression = Literal.fromUrl(link.data.target).get(); - const expressionDate = new Date(expression.timestamp); - let minuteAgo = new Date(); - minuteAgo.setSeconds(minuteAgo.getSeconds() - 30); - if (expressionDate > minuteAgo) { - // TODO: Show message notification - } -} +usePerspectives(appStore.ad4mClient); // Todo: move this initialisation into a composable or higher component? async function initializeApp() { @@ -70,12 +51,6 @@ async function initializeApp() { // Ensure LLM tasks are set up ensureLLMTasks(appStore.ad4mClient.ai); - // Listen for new messages (clean up previous listener if re-mounted) - cleanupLinkAdded?.(); - cleanupLinkAdded = onLinkAdded((p: PerspectiveProxy, link: LinkExpression) => { - if (link.data.predicate === EntryType.Message) gotNewMessage(p, link); - }); - // Todo: Version checking for ad4m / flux compatibility const { ad4mExecutorVersion } = await appStore.ad4mClient.runtime.info(); const isIncompatible = semver.gt(dependencies['@coasys/ad4m'], ad4mExecutorVersion); @@ -85,8 +60,4 @@ async function initializeApp() { } onMounted(async () => initializeApp()); -onUnmounted(() => { - cleanupLinkAdded?.(); - cleanupLinkAdded = undefined; -}); diff --git a/app/src/views/main/community/CommunityView.vue b/app/src/views/main/community/CommunityView.vue index 4fb21c743..87721aa0d 100644 --- a/app/src/views/main/community/CommunityView.vue +++ b/app/src/views/main/community/CommunityView.vue @@ -62,9 +62,9 @@ @@ -144,6 +144,7 @@ const { } = communityService; function navigateToChannel(channelId?: string) { + if (!channelId) return; router.push({ name: 'channel', params: { communityId, channelId } }); } diff --git a/app/src/views/main/community/channel/modals/ChangeChannelModal.vue b/app/src/views/main/community/channel/modals/ChangeChannelModal.vue index fd3681da4..0663f8cfb 100644 --- a/app/src/views/main/community/channel/modals/ChangeChannelModal.vue +++ b/app/src/views/main/community/channel/modals/ChangeChannelModal.vue @@ -22,7 +22,7 @@ import { useCommunityService } from '@/composables/useCommunityService'; import { useRouteParams } from '@/composables/useRouteParams'; import { restoreChannelPrefix } from '@/utils/routeUtils'; -import { App } from '@coasys/flux-api'; +import { App, Channel } from '@coasys/flux-api'; import { computed, ref, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; @@ -35,13 +35,23 @@ const { perspective, allChannels } = useCommunityService(); const channel = computed(() => allChannels.value.find((c) => c.id === restoreChannelPrefix(channelId.value))); const views = ref([]); +let channelLoadSeq = 0; watch( channel, async (newChannel) => { - if (newChannel) { - await newChannel.get({ views: true }); - views.value = newChannel.views; - } else { + const seq = ++channelLoadSeq; + if (!newChannel || !perspective) { + views.value = []; + return; + } + + try { + const fullChannel = new Channel(perspective, newChannel.id); + await fullChannel.get({ views: true }); + if (seq !== channelLoadSeq) return; + views.value = fullChannel.views ?? []; + } catch { + if (seq !== channelLoadSeq) return; views.value = []; } }, diff --git a/app/src/views/main/community/channel/modals/ManageChannelPluginsModal.vue b/app/src/views/main/community/channel/modals/ManageChannelPluginsModal.vue index d5f6adf0c..d5aa9a451 100644 --- a/app/src/views/main/community/channel/modals/ManageChannelPluginsModal.vue +++ b/app/src/views/main/community/channel/modals/ManageChannelPluginsModal.vue @@ -121,15 +121,28 @@ const filteredPackages = computed((): FluxApp[] => ); const views = ref([]); +let viewsLoadSeq = 0; watch( channel, async (newChannel) => { - if (newChannel) { - await newChannel.get({ views: true }); - views.value = newChannel.views; - selectedPlugins.value = newChannel.views; - } else { + const seq = ++viewsLoadSeq; + if (!newChannel || !perspective) { + views.value = []; + selectedPlugins.value = []; + return; + } + + try { + const fullChannel = new Channel(perspective, newChannel.id); + await fullChannel.get({ views: true }); + if (seq !== viewsLoadSeq) return; + const nextViews = fullChannel.views ?? []; + views.value = nextViews; + selectedPlugins.value = [...nextViews]; + } catch { + if (seq !== viewsLoadSeq) return; views.value = []; + selectedPlugins.value = []; } }, { immediate: true }, diff --git a/app/src/views/main/community/sidebar/SidebarItem.vue b/app/src/views/main/community/sidebar/SidebarItem.vue index 64c5882a5..ca567992e 100644 --- a/app/src/views/main/community/sidebar/SidebarItem.vue +++ b/app/src/views/main/community/sidebar/SidebarItem.vue @@ -128,7 +128,7 @@ function navigateToChannel() { function expandIfInNestedChannel() { // Expand the item when the user navigates to a channel included in its children const currentChannelId = route.params.channelId as string; - const inNestedChannel = item.children?.some((c: any) => stripChannelPrefix(c.channel.id) === currentChannelId); + const inNestedChannel = item.children?.some((c: any) => c.channel && stripChannelPrefix(c.channel.id) === currentChannelId); if (inNestedChannel) expanded.value = true; } diff --git a/app/src/views/main/community/sidebar/SidebarList.vue b/app/src/views/main/community/sidebar/SidebarList.vue index 295b8458e..48a61d5b9 100644 --- a/app/src/views/main/community/sidebar/SidebarList.vue +++ b/app/src/views/main/community/sidebar/SidebarList.vue @@ -21,7 +21,7 @@ - +