diff --git a/examples/service-clients/azure-client/external-controller/src/app.ts b/examples/service-clients/azure-client/external-controller/src/app.ts index 1853b3c5707e..ed7d1443cf65 100644 --- a/examples/service-clients/azure-client/external-controller/src/app.ts +++ b/examples/service-clients/azure-client/external-controller/src/app.ts @@ -225,6 +225,7 @@ async function start(): Promise { const contentDiv = document.querySelector("#content") as HTMLDivElement; contentDiv.append( makeAppView( + container, [diceRollerController1, diceRollerController2], { presence, lastRoll: states.lastRoll }, services.audience, diff --git a/examples/service-clients/azure-client/external-controller/src/view.ts b/examples/service-clients/azure-client/external-controller/src/view.ts index 8a1938ddf9c8..de67a6e760f6 100644 --- a/examples/service-clients/azure-client/external-controller/src/view.ts +++ b/examples/service-clients/azure-client/external-controller/src/view.ts @@ -5,6 +5,7 @@ import type { IPresence, LatestValueManager } from "@fluid-experimental/presence"; import { AzureMember, IAzureAudience } from "@fluidframework/azure-client"; +import type { IFluidContainer } from "fluid-framework"; import { ICustomUserDetails } from "./app.js"; import { IDiceRollerController } from "./controller.js"; @@ -171,6 +172,16 @@ function makePresenceView( const update = `client ${name === undefined ? "(unnamed)" : `named ${name}`} with id ${attendee.sessionId} joined`; addLogEntry(logContentDiv, update); }); + + presenceConfig.presence.events.on("attendeeDisconnected", (attendee) => { + // Filter for remote attendees + const self = audience.getMyself(); + if (self && attendee !== presenceConfig.presence.getAttendee(self.currentConnection)) { + const name = audience.getMembers().get(attendee.connectionId())?.name; + const update = `client ${name === undefined ? "(unnamed)" : `named ${name}`} with id ${attendee.sessionId} left`; + addLogEntry(logContentDiv, update); + } + }); } logDiv.append(logHeaderDiv, logContentDiv); @@ -185,7 +196,67 @@ function makePresenceView( return presenceDiv; } +function makeAttendeeView(container: IFluidContainer, presence?: IPresence): HTMLDivElement { + const attendeeDiv = document.createElement("div"); + if (presence === undefined) { + attendeeDiv.textContent = "No presence provided"; + return attendeeDiv; + } + + attendeeDiv.style.display = "flex"; + attendeeDiv.style.justifyContent = "center"; + attendeeDiv.style.margin = "70px"; + + presence.events.on("attendeeJoined", () => { + updateAttendees(presence, attendeeDiv); + updateButton(container, attendeeDiv); + }); + presence.events.on("attendeeDisconnected", () => { + updateAttendees(presence, attendeeDiv); + updateButton(container, attendeeDiv); + }); + container.on("connected", () => { + updateAttendees(presence, attendeeDiv); + updateButton(container, attendeeDiv); + }); + container.on("disconnected", () => { + updateAttendees(presence, attendeeDiv); + updateButton(container, attendeeDiv); + }); + + return attendeeDiv; +} + +function updateButton(container: IFluidContainer, attendeeDiv: HTMLDivElement): void { + const toggleButton = document.createElement("button"); + + toggleButton.style.marginLeft = "10px"; + + const connected = container.connectionState === 2; + + toggleButton.addEventListener("click", () => { + if (connected) { + container.disconnect(); + } else { + container.connect(); + } + }); + toggleButton.innerHTML = `${connected ? "Disconnect" : "Connect"}`; + attendeeDiv.append(toggleButton); +} + +function updateAttendees(presence: IPresence, attendeeDiv: HTMLDivElement): void { + // Clear the existing attendees + attendeeDiv.innerHTML = ""; + const attendeeDivEntry = document.createElement("div"); + for (const attendee of presence.getAttendees()) { + attendeeDivEntry.innerHTML += `Attendee ${attendee.sessionId} is ${attendee.getStatus()}
`; + attendeeDiv.append(attendeeDivEntry); + } +} + export function makeAppView( + container: IFluidContainer, diceRollerControllers: IDiceRollerController[], // Biome insist on no semicolon - https://dev.azure.com/fluidframework/internal/_workitems/edit/9083 // eslint-disable-next-line @typescript-eslint/member-delimiter-style @@ -204,7 +275,9 @@ export function makeAppView( const presenceView = makePresenceView(presenceConfig, audience); + const attendeeView = makeAttendeeView(container, presenceConfig?.presence); + const wrapperDiv = document.createElement("div"); - wrapperDiv.append(diceView, audienceView, presenceView); + wrapperDiv.append(diceView, audienceView, presenceView, attendeeView); return wrapperDiv; } diff --git a/examples/service-clients/azure-client/external-controller/tests/index.ts b/examples/service-clients/azure-client/external-controller/tests/index.ts index 1a77b3c41ed5..fd0e931f068a 100644 --- a/examples/service-clients/azure-client/external-controller/tests/index.ts +++ b/examples/service-clients/azure-client/external-controller/tests/index.ts @@ -140,7 +140,7 @@ async function createContainerAndRenderInElement( const diceRollerController = new DiceRollerController(sharedMap1, () => {}); const diceRollerController2 = new DiceRollerController(sharedMap2, () => {}); - element.append(makeAppView([diceRollerController, diceRollerController2])); + element.append(makeAppView(fluidContainer, [diceRollerController, diceRollerController2])); } /** diff --git a/packages/framework/presence/src/presenceManager.ts b/packages/framework/presence/src/presenceManager.ts index 4cd2938a2715..6b3b0c260229 100644 --- a/packages/framework/presence/src/presenceManager.ts +++ b/packages/framework/presence/src/presenceManager.ts @@ -85,6 +85,12 @@ class PresenceManager runtime.on("connected", this.onConnect.bind(this)); + runtime.on("disconnected", () => { + if (runtime.clientId !== undefined) { + this.removeClientConnectionId(runtime.clientId); + } + }); + // Check if already connected at the time of construction. // If constructed during data store load, the runtime may already be connected // and the "connected" event will be raised during completion. With construction diff --git a/packages/framework/presence/src/systemWorkspace.ts b/packages/framework/presence/src/systemWorkspace.ts index 32d63aea9d92..0687d7b321ba 100644 --- a/packages/framework/presence/src/systemWorkspace.ts +++ b/packages/framework/presence/src/systemWorkspace.ts @@ -220,6 +220,8 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace { // Update the order and current connection id. attendee.order = order; attendee.connectionId = connectionId; + attendee.getStatus = () => SessionClientStatus.Connected; + isNew = true; } // Always update entry for the connection id. (Okay if already set.) this.attendees.set(clientConnectionId, attendee); diff --git a/packages/framework/presence/src/test/mockEphemeralRuntime.ts b/packages/framework/presence/src/test/mockEphemeralRuntime.ts index 7cc04445dba3..5a31ad2e9292 100644 --- a/packages/framework/presence/src/test/mockEphemeralRuntime.ts +++ b/packages/framework/presence/src/test/mockEphemeralRuntime.ts @@ -51,8 +51,10 @@ export class MockEphemeralRuntime implements IEphemeralRuntime { public readonly listeners: { connected: ((clientId: ClientConnectionId) => void)[]; + disconnected: (() => void)[]; } = { connected: [], + disconnected: [], }; private isSupportedEvent(event: string): event is keyof typeof this.listeners { return event in this.listeners; diff --git a/packages/framework/presence/src/test/presenceManager.spec.ts b/packages/framework/presence/src/test/presenceManager.spec.ts index ccf5821761cf..c89838cb86d4 100644 --- a/packages/framework/presence/src/test/presenceManager.spec.ts +++ b/packages/framework/presence/src/test/presenceManager.spec.ts @@ -217,58 +217,6 @@ describe("Presence", () => { // Act & Verify - simulate duplicate join message from client presence.processSignal("", initialAttendeeSignal, false); }); - - it("is NOT announced when rejoined with different connection and current information is updated", () => { - // Setup - assert(newAttendee !== undefined, "No attendee was set in beforeEach"); - - const updatedClientConnectionId = "client5"; - clock.tick(20); - const rejoinedAttendeeSignal = generateBasicClientJoin(clock.now - 20, { - averageLatency: 20, - clientSessionId: newAttendeeSessionId, // Same session id - clientConnectionId: updatedClientConnectionId, // Different connection id - connectionOrder: 1, - updateProviders: ["client2"], - }); - rejoinedAttendeeSignal.content.data["system:presence"].clientToSessionId[ - initialAttendeeConnectionId - ] = - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - initialAttendeeSignal.content.data["system:presence"].clientToSessionId[ - initialAttendeeConnectionId - ]!; - - // Act - simulate new join message from same client (without disconnect) - presence.processSignal("", rejoinedAttendeeSignal, false); - - // Verify - // Session id is unchanged - assert.equal( - newAttendee.sessionId, - newAttendeeSessionId, - "Attendee has wrong session id", - ); - // Current connection id is updated - assert( - newAttendee.connectionId() === updatedClientConnectionId, - "Attendee does not have updated client connection id", - ); - // Attendee is available via new connection id - const attendeeViaUpdatedId = presence.getAttendee(updatedClientConnectionId); - assert.equal( - attendeeViaUpdatedId, - newAttendee, - "getAttendee returned wrong attendee for updated connection id", - ); - // Attendee is available via old connection id - const attendeeViaOriginalId = presence.getAttendee(initialAttendeeConnectionId); - assert.equal( - attendeeViaOriginalId, - newAttendee, - "getAttendee returned wrong attendee for original connection id", - ); - }); }); }); });