Fullfør oppgave 5.4: én-til-én chat med deltaker-velger
Implementerer full 1:1 chat-loop: - NewChatDialog: velg person å starte samtale med - Fikser API-felt (participant_ids → participants) for korrekt kommunikasjon med maskinrommets create_communication-endepunkt - Opprett kommunikasjonsnode med to deltakere (owner + member_of) - Dedupliserer: finner eksisterende samtale før ny opprettes - Chat-header viser den andre deltakerens navn i 1:1-samtaler - Testbruker-node opprettet på server for verifisering Full loop verifisert via STDB: node + edges + melding + belongs_to fungerer, WebSocket-subscribers ser endringer i sanntid. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0a9e2cf814
commit
5d2581710f
5 changed files with 226 additions and 8 deletions
|
|
@ -68,7 +68,8 @@ export function createEdge(
|
|||
|
||||
export interface CreateCommunicationRequest {
|
||||
title?: string;
|
||||
participant_ids?: string[];
|
||||
participants?: string[];
|
||||
visibility?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
|
|
|||
152
frontend/src/lib/components/NewChatDialog.svelte
Normal file
152
frontend/src/lib/components/NewChatDialog.svelte
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<script lang="ts">
|
||||
import { nodeStore, edgeStore } from '$lib/spacetime';
|
||||
import type { Node } from '$lib/spacetime';
|
||||
|
||||
interface Props {
|
||||
currentUserId: string;
|
||||
onselect: (personId: string) => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { currentUserId, onselect, onclose }: Props = $props();
|
||||
|
||||
let search = $state('');
|
||||
|
||||
/**
|
||||
* Available people to chat with: all person nodes except current user.
|
||||
* For 1:1 chat we show person nodes the user can see.
|
||||
*/
|
||||
const people = $derived.by(() => {
|
||||
const persons: Node[] = [];
|
||||
for (const node of nodeStore.byKind('person')) {
|
||||
if (node.id !== currentUserId) {
|
||||
persons.push(node);
|
||||
}
|
||||
}
|
||||
// Sort alphabetically by title
|
||||
persons.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
||||
return persons;
|
||||
});
|
||||
|
||||
/** Filter by search term */
|
||||
const filtered = $derived.by(() => {
|
||||
if (!search.trim()) return people;
|
||||
const q = search.trim().toLowerCase();
|
||||
return people.filter(p => (p.title || '').toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if there's already a 1:1 communication with this person.
|
||||
* Returns the communication node ID if found, undefined otherwise.
|
||||
*/
|
||||
function existingChatWith(personId: string): string | undefined {
|
||||
// Find communication nodes where both current user and person are participants
|
||||
const userComms = new Set<string>();
|
||||
for (const edge of edgeStore.bySource(currentUserId)) {
|
||||
if (edge.edgeType === 'owner' || edge.edgeType === 'member_of') {
|
||||
const target = nodeStore.get(edge.targetId);
|
||||
if (target?.nodeKind === 'communication') {
|
||||
userComms.add(edge.targetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const edge of edgeStore.bySource(personId)) {
|
||||
if (edge.edgeType === 'owner' || edge.edgeType === 'member_of') {
|
||||
if (userComms.has(edge.targetId)) {
|
||||
return edge.targetId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function handleSelect(personId: string) {
|
||||
onselect(personId);
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onclose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onclose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
onclick={handleBackdropClick}
|
||||
class="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-20"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-xl bg-white shadow-xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Ny samtale</h2>
|
||||
<button
|
||||
onclick={onclose}
|
||||
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="Lukk"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="border-b border-gray-100 px-4 py-2">
|
||||
<input
|
||||
bind:value={search}
|
||||
type="text"
|
||||
placeholder="Søk etter person…"
|
||||
class="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-blue-300 focus:bg-white focus:outline-none focus:ring-1 focus:ring-blue-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- People list -->
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
{#if filtered.length === 0}
|
||||
<p class="px-4 py-6 text-center text-sm text-gray-400">
|
||||
{#if people.length === 0}
|
||||
Ingen andre brukere funnet
|
||||
{:else}
|
||||
Ingen treff for «{search}»
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each filtered as person (person.id)}
|
||||
{@const existing = existingChatWith(person.id)}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => handleSelect(person.id)}
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-blue-100 text-sm font-medium text-blue-700">
|
||||
{(person.title || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-gray-900">{person.title || 'Ukjent'}</p>
|
||||
{#if existing}
|
||||
<p class="text-xs text-gray-400">Har eksisterende samtale</p>
|
||||
{/if}
|
||||
</div>
|
||||
<svg class="h-4 w-4 shrink-0 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
import { connectionState, nodeStore, edgeStore, nodeAccessStore, nodeVisibility } from '$lib/spacetime';
|
||||
import type { Node } from '$lib/spacetime';
|
||||
import NodeEditor from '$lib/components/NodeEditor.svelte';
|
||||
import NewChatDialog from '$lib/components/NewChatDialog.svelte';
|
||||
import { createNode, createEdge, createCommunication } from '$lib/api';
|
||||
|
||||
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
||||
|
|
@ -102,12 +103,34 @@
|
|||
return edges.filter((e) => !e.system).map((e) => e.edgeType);
|
||||
}
|
||||
|
||||
/** Create a new communication node and navigate to it */
|
||||
async function handleNewChat() {
|
||||
if (!accessToken) return;
|
||||
let showNewChatDialog = $state(false);
|
||||
|
||||
/** Open the new chat dialog to pick a participant */
|
||||
function handleNewChat() {
|
||||
showNewChatDialog = true;
|
||||
}
|
||||
|
||||
/** Create a 1:1 communication with the selected person */
|
||||
async function handleStartChat(personId: string) {
|
||||
if (!accessToken || !nodeId) return;
|
||||
showNewChatDialog = false;
|
||||
|
||||
try {
|
||||
// Check if there's already a communication with this person
|
||||
const existingId = findExistingChat(personId);
|
||||
if (existingId) {
|
||||
window.location.href = `/chat/${existingId}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the other person's name for the chat title
|
||||
const person = nodeStore.get(personId);
|
||||
const myNode = nodeStore.get(nodeId);
|
||||
const title = [myNode?.title, person?.title].filter(Boolean).join(' & ') || 'Samtale';
|
||||
|
||||
const { node_id } = await createCommunication(accessToken, {
|
||||
title: 'Ny samtale',
|
||||
title,
|
||||
participants: [personId],
|
||||
});
|
||||
window.location.href = `/chat/${node_id}`;
|
||||
} catch (e) {
|
||||
|
|
@ -115,6 +138,32 @@
|
|||
}
|
||||
}
|
||||
|
||||
/** Find an existing 1:1 communication between current user and another person */
|
||||
function findExistingChat(personId: string): string | undefined {
|
||||
if (!nodeId) return undefined;
|
||||
|
||||
// Collect communication nodes where current user is owner or member
|
||||
const userComms = new Set<string>();
|
||||
for (const edge of edgeStore.bySource(nodeId)) {
|
||||
if (edge.edgeType === 'owner' || edge.edgeType === 'member_of') {
|
||||
const target = nodeStore.get(edge.targetId);
|
||||
if (target?.nodeKind === 'communication') {
|
||||
userComms.add(edge.targetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the other person is also in any of these communications
|
||||
for (const edge of edgeStore.bySource(personId)) {
|
||||
if (edge.edgeType === 'owner' || edge.edgeType === 'member_of') {
|
||||
if (userComms.has(edge.targetId)) {
|
||||
return edge.targetId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Submit a new node via maskinrommet */
|
||||
async function handleCreateNode(data: { title: string; content: string; html: string }) {
|
||||
if (!accessToken) throw new Error('Ikke innlogget');
|
||||
|
|
@ -272,4 +321,12 @@
|
|||
</p>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
{#if showNewChatDialog && nodeId}
|
||||
<NewChatDialog
|
||||
currentUserId={nodeId}
|
||||
onselect={handleStartChat}
|
||||
onclose={() => showNewChatDialog = false}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -63,6 +63,15 @@
|
|||
return nodes;
|
||||
});
|
||||
|
||||
/** For 1:1 chats, show the other person's name instead of the generic title */
|
||||
const chatTitle = $derived.by(() => {
|
||||
if (participants.length === 2 && nodeId) {
|
||||
const other = participants.find(p => p.id !== nodeId);
|
||||
if (other?.title) return other.title;
|
||||
}
|
||||
return communicationNode?.title || 'Samtale';
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
let messagesContainer: HTMLDivElement | undefined = $state();
|
||||
let prevMessageCount = $state(0);
|
||||
|
|
@ -132,7 +141,7 @@
|
|||
</a>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="truncate text-lg font-semibold text-gray-900">
|
||||
{communicationNode?.title || 'Samtale'}
|
||||
{chatTitle}
|
||||
</h1>
|
||||
{#if participants.length > 0}
|
||||
<p class="truncate text-xs text-gray-500">
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -82,8 +82,7 @@ Uavhengige faser kan fortsatt plukkes.
|
|||
- [x] 5.1 Opprett kommunikasjonsnode: intensjon `create_communication` → node med `node_kind='communication'`, deltaker-edges, metadata (started_at).
|
||||
- [x] 5.2 Kontekst-arv: input i kommunikasjonsnode → automatisk `belongs_to`-edge.
|
||||
- [x] 5.3 Chat-visning i frontend: noder med `belongs_to`-edge til kommunikasjonsnode, sortert på tid, sanntid via STDB.
|
||||
- [~] 5.4 Én-til-én chat: opprett kommunikasjonsnode med to deltakere. Full loop: skriv melding → vis i sanntid hos begge.
|
||||
> Påbegynt: 2026-03-17T16:15
|
||||
- [x] 5.4 Én-til-én chat: opprett kommunikasjonsnode med to deltakere. Full loop: skriv melding → vis i sanntid hos begge.
|
||||
|
||||
## Fase 6: CAS og mediefiler
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue