diff --git a/packages/http-routes/src/routes/web/people-relationships.test.ts b/packages/http-routes/src/routes/web/people-relationships.test.ts index 12a88647..d08c1f88 100644 --- a/packages/http-routes/src/routes/web/people-relationships.test.ts +++ b/packages/http-routes/src/routes/web/people-relationships.test.ts @@ -75,7 +75,7 @@ function emptyNeighborhoodResponse( class FakePeopleRelationshipsService implements PeopleRelationshipsService { graphCalls: Array<{ acceptStale?: boolean; timeRangePreset?: string; query?: string; graphScope?: string }> = [] recomputeCalls: Array<{ timeRangePreset?: string; query?: string; graphScope?: string }> = [] - neighborhoodCalls: Array<{ key: string; acceptStale?: boolean; timeRangePreset?: string }> = [] + neighborhoodCalls: Array<{ key: string; acceptStale?: boolean; timeRangePreset?: string; graphScope?: string }> = [] closeCalls = 0 getGraph(options?: { @@ -95,9 +95,14 @@ class FakePeopleRelationshipsService implements PeopleRelationshipsService { getNeighborhood( key: string, - options?: { acceptStale?: boolean; timeRangePreset?: string } + options?: { acceptStale?: boolean; timeRangePreset?: string; graphScope?: string } ): PeopleRelationshipsNeighborhoodResponse { - this.neighborhoodCalls.push({ key, acceptStale: options?.acceptStale, timeRangePreset: options?.timeRangePreset }) + this.neighborhoodCalls.push({ + key, + acceptStale: options?.acceptStale, + timeRangePreset: options?.timeRangePreset, + graphScope: options?.graphScope, + }) return emptyNeighborhoodResponse('missing') } @@ -199,7 +204,7 @@ test('POST /_web/people/relationships/recompute forwards time range, search quer assert.deepEqual(service.recomputeCalls, [{ timeRangePreset: '3y', query: 'Bob', graphScope: 'close' }]) }) -test('GET /_web/people/relationships/:key/neighborhood forwards decoded contact key', async (t) => { +test('GET /_web/people/relationships/:key/neighborhood forwards contact key and graph scope', async (t) => { const service = new FakePeopleRelationshipsService() const app = Fastify() t.after(async () => app.close()) @@ -208,11 +213,15 @@ test('GET /_web/people/relationships/:key/neighborhood forwards decoded contact const response = await app.inject({ method: 'GET', - url: `/_web/people/relationships/${encodeURIComponent('weixin:alice')}/neighborhood?acceptStale=1&timeRange=5y`, + url: `/_web/people/relationships/${encodeURIComponent( + 'weixin:alice' + )}/neighborhood?acceptStale=1&timeRange=5y&scope=friends`, }) assert.equal(response.statusCode, 200) - assert.deepEqual(service.neighborhoodCalls, [{ key: 'weixin:alice', acceptStale: true, timeRangePreset: '5y' }]) + assert.deepEqual(service.neighborhoodCalls, [ + { key: 'weixin:alice', acceptStale: true, timeRangePreset: '5y', graphScope: 'friends' }, + ]) }) test('closes people relationships service when Fastify app closes', async () => { diff --git a/packages/http-routes/src/routes/web/people-relationships.ts b/packages/http-routes/src/routes/web/people-relationships.ts index 85cc31e7..24a9f44e 100644 --- a/packages/http-routes/src/routes/web/people-relationships.ts +++ b/packages/http-routes/src/routes/web/people-relationships.ts @@ -57,6 +57,7 @@ export function registerPeopleRelationshipsRoutes(server: FastifyInstance, ctx: return service.getNeighborhood(request.params.key, { acceptStale: isTruthy(request.query.acceptStale), timeRangePreset: parseContactsTimeRangePreset(request.query.timeRange), + graphScope: parsePeopleRelationshipsGraphScope(request.query.scope), }) } ) diff --git a/packages/node-runtime/src/services/people/relationships/service.test.ts b/packages/node-runtime/src/services/people/relationships/service.test.ts index 3e65cffd..95d96e7d 100644 --- a/packages/node-runtime/src/services/people/relationships/service.test.ts +++ b/packages/node-runtime/src/services/people/relationships/service.test.ts @@ -407,6 +407,56 @@ test('returns a friends relationships graph without groupmate nodes while keepin } }) +test('keeps friend neighborhoods limited to friends and their relationships', () => { + const dir = makeTempDir() + try { + const service = createPeopleRelationshipsService({ + adapter: makeAdapter(), + systemDir: dir, + runner: async () => { + throw new Error('runner should not be called for fresh injected snapshot') + }, + }) + const snapshot = makeSnapshot(makeFreshSignature()) + const friend = makeNode({ + key: 'weixin:friend', + displayName: 'Friend', + pool: 'friend', + friendSource: 'private', + rank: 5, + score: 60, + }) + snapshot.nodes = [...snapshot.nodes, friend] + snapshot.edges = [...snapshot.edges, makeEdge('weixin:alice', friend.key, 10)] + service.replaceSnapshotForTests?.(snapshot) + + const response = service.getNeighborhood('weixin:alice', { + acceptStale: true, + graphScope: 'friends', + }) + const keys = response.graph.nodes.map((node) => node.key) + + assert.deepEqual(keys, ['weixin:alice', friend.key]) + assert.equal( + response.graph.nodes.every((node) => node.kind === 'owner' || node.pool === 'friend'), + true + ) + assert.deepEqual( + response.graph.edges.map((edge) => [edge.sourceKey, edge.targetKey]), + [['weixin:alice', friend.key]] + ) + + const groupmateResponse = service.getNeighborhood('weixin:bob', { + acceptStale: true, + graphScope: 'friends', + }) + assert.equal(groupmateResponse.contact, null) + assert.deepEqual(groupmateResponse.graph, { nodes: [], edges: [], communities: [] }) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + test('applies manual contact friend overrides to people relationships graph scopes', () => { const dir = makeTempDir() try { diff --git a/packages/node-runtime/src/services/people/relationships/service.ts b/packages/node-runtime/src/services/people/relationships/service.ts index f4a7467e..07d94a6f 100644 --- a/packages/node-runtime/src/services/people/relationships/service.ts +++ b/packages/node-runtime/src/services/people/relationships/service.ts @@ -126,8 +126,11 @@ class DefaultPeopleRelationshipsService implements PeopleRelationshipsService { const snapshot = this.getSnapshotForResponse(timeRangePreset) const status = this.getCacheStatus(signature, timeRangePreset) const includeSnapshot = shouldIncludeSnapshot(status, options.acceptStale) - const graph = includeSnapshot && snapshot ? buildPeopleRelationshipsNeighborhoodGraph(snapshot, key) : emptyGraph() - const contact = includeSnapshot ? (snapshot?.nodes.find((node) => node.key === key) ?? null) : null + const graphScope = normalizePeopleRelationshipsGraphScope(options.graphScope) + const scopedSnapshot = snapshot ? buildNeighborhoodSnapshotForScope(snapshot, graphScope) : null + const graph = + includeSnapshot && scopedSnapshot ? buildPeopleRelationshipsNeighborhoodGraph(scopedSnapshot, key) : emptyGraph() + const contact = includeSnapshot ? (scopedSnapshot?.nodes.find((node) => node.key === key) ?? null) : null return { contact, graph, @@ -369,6 +372,21 @@ function buildFriendsRelationshipsGraph(snapshot: PeopleRelationshipsSnapshot): } } +function buildNeighborhoodSnapshotForScope( + snapshot: PeopleRelationshipsSnapshot, + scope: PeopleRelationshipsGraphScope +): PeopleRelationshipsSnapshot { + if (scope !== 'friends') return snapshot + + const graph = buildFriendsRelationshipsGraph(snapshot) + return { + ...snapshot, + nodes: graph.nodes, + edges: graph.edges, + communities: graph.communities, + } +} + function buildCloseRelationshipsGraph(snapshot: PeopleRelationshipsSnapshot): PeopleRelationshipsGraphData { const selectedKeys = new Set() for (const node of snapshot.nodes) { diff --git a/src/pages/people/relationships/components/RelationshipGalaxyCanvas.vue b/src/pages/people/relationships/components/RelationshipGalaxyCanvas.vue index 7ba964b2..10aa7990 100644 --- a/src/pages/people/relationships/components/RelationshipGalaxyCanvas.vue +++ b/src/pages/people/relationships/components/RelationshipGalaxyCanvas.vue @@ -61,6 +61,7 @@ const props = withDefaults( selectedKey?: string | null privacyMode?: boolean safeInsetRight?: number + emphasizeEdges?: boolean label: string ownerLabel: string }>(), @@ -68,6 +69,7 @@ const props = withDefaults( selectedKey: null, privacyMode: false, safeInsetRight: 0, + emphasizeEdges: false, } ) @@ -344,7 +346,7 @@ function renderGraph(shouldFit = false) { const targetX = target.x + offsetX const targetY = target.y + offsetY - const alpha = edge.visibility === 2 ? 0.22 : 0.07 + const alpha = edge.visibility === 2 ? (props.emphasizeEdges ? 0.26 : 0.22) : props.emphasizeEdges ? 0.1 : 0.07 const width = Math.min(1.4, Math.max(0.4, Math.log10(edge.weight + 1) * 0.75)) const edgeColor = getNodeColor(source) edgeLayer.moveTo(sourceX, sourceY).lineTo(targetX, targetY).stroke({ color: edgeColor, width, alpha }) @@ -773,7 +775,14 @@ onMounted(async () => { }) watch( - () => [props.graph.nodes, props.graph.edges, props.selectedKey, props.privacyMode, props.safeInsetRight], + () => [ + props.graph.nodes, + props.graph.edges, + props.selectedKey, + props.privacyMode, + props.safeInsetRight, + props.emphasizeEdges, + ], () => { renderGraph(false) }, diff --git a/src/pages/people/relationships/components/RelationshipGalaxyThreeCanvas.vue b/src/pages/people/relationships/components/RelationshipGalaxyThreeCanvas.vue index 05f7245c..f3eccb41 100644 --- a/src/pages/people/relationships/components/RelationshipGalaxyThreeCanvas.vue +++ b/src/pages/people/relationships/components/RelationshipGalaxyThreeCanvas.vue @@ -63,6 +63,7 @@ const props = withDefaults( selectedKey?: string | null privacyMode?: boolean safeInsetRight?: number + emphasizeEdges?: boolean label: string ownerLabel: string }>(), @@ -70,6 +71,7 @@ const props = withDefaults( selectedKey: null, privacyMode: false, safeInsetRight: 0, + emphasizeEdges: false, } ) @@ -194,7 +196,7 @@ function addThinEdgePaths( const material = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, - opacity: bucket === 'highlight' ? 0.62 : bucket === 'normal' ? 0.21 : 0.05, + opacity: bucket === 'highlight' ? 0.62 : bucket === 'normal' ? (props.emphasizeEdges ? 0.27 : 0.21) : 0.05, linewidth, blending: THREE.NormalBlending, depthTest: true, @@ -847,6 +849,14 @@ watch( { flush: 'post' } ) +watch( + () => props.emphasizeEdges, + () => { + renderGraph(false) + }, + { flush: 'post' } +) + watch( () => props.privacyMode, () => { diff --git a/src/pages/people/relationships/index.vue b/src/pages/people/relationships/index.vue index 55fb44e9..cf6c9b0c 100644 --- a/src/pages/people/relationships/index.vue +++ b/src/pages/people/relationships/index.vue @@ -485,6 +485,7 @@ async function loadNeighborhood(key: string) { const next = await dataService.getPeopleRelationshipNeighborhood(key, { acceptStale: true, timeRangePreset: timeRangePreset.value, + graphScope: graphScope.value, }) if (requestId !== neighborhoodRequestId.value) return @@ -661,6 +662,7 @@ onBeforeUnmount(() => { :selected-key="canvasSelectedKey" :privacy-mode="privacyMode" :safe-inset-right="detailPanelSafeInsetRight" + :emphasize-edges="graphScope === 'friends'" :label="t('relationships.canvas.label3d')" :owner-label="t('relationships.owner.me')" @fallback="handleThreeCanvasFallback" @@ -673,6 +675,7 @@ onBeforeUnmount(() => { :selected-key="canvasSelectedKey" :privacy-mode="privacyMode" :safe-inset-right="detailPanelSafeInsetRight" + :emphasize-edges="graphScope === 'friends'" :label="t('relationships.canvas.label')" :owner-label="t('relationships.owner.me')" @select-node="selectNode" diff --git a/src/services/data/fetch.ts b/src/services/data/fetch.ts index 45629a79..8e3bb0f9 100644 --- a/src/services/data/fetch.ts +++ b/src/services/data/fetch.ts @@ -207,6 +207,7 @@ export class FetchDataAdapter implements DataAdapter { const params = new URLSearchParams() if (options?.acceptStale) params.set('acceptStale', '1') if (options?.timeRangePreset) params.set('timeRange', options.timeRangePreset) + if (options?.graphScope) params.set('scope', options.graphScope) const qs = params.toString() return get(`/people/relationships/${encodeURIComponent(key)}/neighborhood${qs ? `?${qs}` : ''}`) }