Skip to content

feat(client-presence): Expose Presence at Workspaces and State objects #24396

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Apr 21, 2025
13 changes: 13 additions & 0 deletions .changeset/giant-news-appear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@fluidframework/presence": minor
"__section": other
---
Presence object is now accessible from Workspaces and State objects

Users can now access the `Presence` object through `.presence` on all Workspaces and State objects:

`Latest.presence`
`LatestMap.presence`
`Notifications.presence`
`NotificationsWorkspace.presence`
`StatesWorkspace.presence`
5 changes: 5 additions & 0 deletions packages/framework/presence/api-report/presence.alpha.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export interface Latest<T> {
getStateAttendees(): Attendee[];
get local(): InternalUtilityTypes.FullyReadonly<JsonDeserialized<T>>;
set local(value: JsonSerializable<T> & JsonDeserialized<T>);
readonly presence: Presence;
}

// @alpha
Expand Down Expand Up @@ -181,6 +182,7 @@ export interface LatestMap<T, Keys extends string | number = string | number> {
getRemotes(): IterableIterator<LatestMapClientData<T, Keys>>;
getStateAttendees(): Attendee[];
readonly local: StateMap<Keys, T>;
readonly presence: Presence;
}

// @alpha
Expand Down Expand Up @@ -256,6 +258,7 @@ export interface NotificationsManager<T extends InternalUtilityTypes.Notificatio
readonly emit: NotificationEmitter<T>;
readonly events: Listenable<NotificationsManagerEvents>;
readonly notifications: NotificationListenable<T>;
readonly presence: Presence;
}

// @alpha @sealed (undocumented)
Expand All @@ -272,6 +275,7 @@ export type NotificationSubscriptions<E extends InternalUtilityTypes.Notificatio
// @alpha @sealed
export interface NotificationsWorkspace<TSchema extends NotificationsWorkspaceSchema> {
add<TKey extends string, TValue extends InternalTypes.ValueDirectoryOrState<any>, TManager extends NotificationsManager<any>>(key: TKey, manager: InternalTypes.ManagerFactory<TKey, TValue, TManager>): asserts this is NotificationsWorkspace<TSchema & Record<TKey, InternalTypes.ManagerFactory<TKey, TValue, TManager>>>;
readonly presence: Presence;
readonly props: StatesWorkspaceEntries<TSchema>;
}

Expand Down Expand Up @@ -331,6 +335,7 @@ export interface StateMap<K extends string | number, V> {
export interface StatesWorkspace<TSchema extends StatesWorkspaceSchema, TManagerConstraints = unknown> {
add<TKey extends string, TValue extends InternalTypes.ValueDirectoryOrState<any>, TManager extends TManagerConstraints>(key: TKey, manager: InternalTypes.ManagerFactory<TKey, TValue, TManager>): asserts this is StatesWorkspace<TSchema & Record<TKey, InternalTypes.ManagerFactory<TKey, TValue, TManager>>, TManagerConstraints>;
readonly controls: BroadcastControls;
readonly presence: Presence;
readonly props: StatesWorkspaceEntries<TSchema>;
}

Expand Down
11 changes: 10 additions & 1 deletion packages/framework/presence/src/latestMapValueManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { InternalUtilityTypes } from "./exposedUtilityTypes.js";
import type { PostUpdateAction, ValueManager } from "./internalTypes.js";
import { objectEntries, objectKeys } from "./internalUtils.js";
import type { LatestClientData, LatestData, LatestMetadata } from "./latestValueTypes.js";
import type { AttendeeId, Attendee, SpecificAttendee } from "./presence.js";
import type { AttendeeId, Attendee, Presence, SpecificAttendee } from "./presence.js";
import { datastoreFromHandle, type StateDatastore } from "./stateDatastore.js";
import { brandIVM } from "./valueManager.js";

Expand Down Expand Up @@ -311,6 +311,11 @@ class ValueMapImpl<T, K extends string | number> implements StateMap<K, T> {
* @alpha
*/
export interface LatestMap<T, Keys extends string | number = string | number> {
/**
* Containing {@link Presence}
*/
readonly presence: Presence;

/**
* Events for LatestMap.
*/
Expand Down Expand Up @@ -372,6 +377,10 @@ class LatestMapValueManagerImpl<
);
}

public get presence(): Presence {
return this.datastore.presence;
}

public readonly local: StateMap<Keys, T>;

public *getRemotes(): IterableIterator<LatestMapClientData<T, Keys>> {
Expand Down
11 changes: 10 additions & 1 deletion packages/framework/presence/src/latestValueManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { InternalUtilityTypes } from "./exposedUtilityTypes.js";
import type { PostUpdateAction, ValueManager } from "./internalTypes.js";
import { objectEntries } from "./internalUtils.js";
import type { LatestClientData, LatestData } from "./latestValueTypes.js";
import type { Attendee } from "./presence.js";
import type { Attendee, Presence } from "./presence.js";
import { datastoreFromHandle, type StateDatastore } from "./stateDatastore.js";
import { brandIVM } from "./valueManager.js";

Expand Down Expand Up @@ -54,6 +54,11 @@ export interface LatestEvents<T> {
* @alpha
*/
export interface Latest<T> {
/**
* Containing {@link Presence}
*/
readonly presence: Presence;

/**
* Events for Latest.
*/
Expand Down Expand Up @@ -102,6 +107,10 @@ class LatestValueManagerImpl<T, Key extends string>
this.controls = new OptionalBroadcastControl(controlSettings);
}

public get presence(): Presence {
return this.datastore.presence;
}

public get local(): InternalUtilityTypes.FullyReadonly<JsonDeserialized<T>> {
return this.value.value;
}
Expand Down
11 changes: 10 additions & 1 deletion packages/framework/presence/src/notificationsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { JsonTypeWith } from "@fluidframework/core-interfaces/internal";
import type { InternalTypes } from "./exposedInternalTypes.js";
import type { InternalUtilityTypes } from "./exposedUtilityTypes.js";
import type { PostUpdateAction, ValueManager } from "./internalTypes.js";
import type { Attendee } from "./presence.js";
import type { Attendee, Presence } from "./presence.js";
import { datastoreFromHandle, type StateDatastore } from "./stateDatastore.js";
import { brandIVM } from "./valueManager.js";

Expand Down Expand Up @@ -130,6 +130,11 @@ export interface NotificationEmitter<E extends InternalUtilityTypes.Notification
export interface NotificationsManager<
T extends InternalUtilityTypes.NotificationListeners<T>,
> {
/**
* Containing {@link Presence}
*/
readonly presence: Presence;

/**
* Events for Notifications manager.
*/
Expand Down Expand Up @@ -222,6 +227,10 @@ class NotificationsManagerImpl<
}
}

public get presence(): Presence {
return this.datastore.presence;
}

public update(
attendee: Attendee,
_received: number,
Expand Down
4 changes: 3 additions & 1 deletion packages/framework/presence/src/presenceDatastoreManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { ClientConnectionId } from "./baseTypes.js";
import type { BroadcastControlSettings } from "./broadcastControls.js";
import type { IEphemeralRuntime, PostUpdateAction } from "./internalTypes.js";
import { objectEntries } from "./internalUtils.js";
import type { AttendeeId, Attendee, PresenceEvents } from "./presence.js";
import type { AttendeeId, Attendee, Presence, PresenceEvents } from "./presence.js";
import type {
ClientUpdateEntry,
RuntimeLocalUpdateOptions,
Expand Down Expand Up @@ -153,6 +153,7 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager {
private readonly lookupClient: (clientId: AttendeeId) => Attendee,
private readonly logger: ITelemetryLoggerExt | undefined,
private readonly events: IEmitter<PresenceEvents>,
private readonly presence: Presence,
systemWorkspaceDatastore: SystemWorkspaceDatastore,
systemWorkspace: StatesWorkspaceEntry<StatesWorkspaceSchema>,
) {
Expand Down Expand Up @@ -219,6 +220,7 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager {

const entry = createPresenceStates(
{
presence: this.presence,
attendeeId: this.attendeeId,
lookupClient: this.lookupClient,
localUpdate,
Expand Down
3 changes: 3 additions & 0 deletions packages/framework/presence/src/presenceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class PresenceManager implements Presence, PresenceExtensionInterface {
runtime,
this.events,
this.mc?.logger,
this,
);
this.attendees = this.systemWorkspace;

Expand Down Expand Up @@ -138,6 +139,7 @@ function setupSubComponents(
events: Listenable<PresenceEvents & AttendeesEvents> &
IEmitter<PresenceEvents & AttendeesEvents>,
logger: ITelemetryLoggerExt | undefined,
presence: Presence,
): [PresenceDatastoreManager, SystemWorkspace] {
const systemWorkspaceDatastore: SystemWorkspaceDatastore = {
clientToSessionId: {},
Expand All @@ -154,6 +156,7 @@ function setupSubComponents(
systemWorkspaceConfig.workspace.getAttendee.bind(systemWorkspaceConfig.workspace),
logger,
events,
presence,
systemWorkspaceDatastore,
systemWorkspaceConfig.statesEntry,
);
Expand Down
7 changes: 6 additions & 1 deletion packages/framework/presence/src/presenceStates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { InternalTypes } from "./exposedInternalTypes.js";
import type { ClientRecord, PostUpdateAction } from "./internalTypes.js";
import type { RecordEntryTypes } from "./internalUtils.js";
import { getOrCreateRecord, objectEntries } from "./internalUtils.js";
import type { AttendeeId, Attendee } from "./presence.js";
import type { AttendeeId, Attendee, Presence } from "./presence.js";
import type { LocalStateUpdateOptions, StateDatastore } from "./stateDatastore.js";
import { handleFromDatastore } from "./stateDatastore.js";
import type { StatesWorkspace, StatesWorkspaceSchema } from "./types.js";
Expand Down Expand Up @@ -51,6 +51,7 @@ export interface RuntimeLocalUpdateOptions {
* @internal
*/
export interface PresenceRuntime {
readonly presence: Presence;
readonly attendeeId: AttendeeId;
lookupClient(clientId: ClientConnectionId): Attendee;
localUpdate(
Expand Down Expand Up @@ -306,6 +307,10 @@ class PresenceStatesImpl<TSchema extends StatesWorkspaceSchema>
}
}

public get presence(): Presence {
return this.runtime.presence;
}

public knownValues<Key extends keyof TSchema & string>(
key: Key,
): {
Expand Down
3 changes: 2 additions & 1 deletion packages/framework/presence/src/stateDatastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import type { ClientConnectionId } from "./baseTypes.js";
import type { InternalTypes } from "./exposedInternalTypes.js";
import type { ClientRecord } from "./internalTypes.js";
import type { Attendee, AttendeeId } from "./presence.js";
import type { Attendee, AttendeeId, Presence } from "./presence.js";

// type StateDatastoreSchemaNode<
// TValue extends InternalTypes.ValueDirectoryOrState<any> = InternalTypes.ValueDirectoryOrState<unknown>,
Expand Down Expand Up @@ -42,6 +42,7 @@ export interface StateDatastore<
TKey extends string,
TValue extends InternalTypes.ValueDirectoryOrState<any>,
> {
readonly presence: Presence;
localUpdate(
key: TKey,
value: TValue & {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ describe("Presence", () => {
mapVM.local.delete("key1");
assert.strictEqual(localRemovalCount, 1);
});

it(".presence provides Presence it was created under", () => {
const presence = createPresenceManager(new MockEphemeralRuntime());
const states = presence.states.getWorkspace(testWorkspaceName, {
fixedMap: StateFactory.latestMap({ key1: { x: 0, y: 0 } }),
});

assert.strictEqual(states.props.fixedMap.presence, presence);
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ describe("Presence", () => {
});
assert.deepStrictEqual(states.props.arr.local, [1, 2, 3]);
});

it(".presence provides Presence it was created under", () => {
const states = presence.states.getWorkspace(testWorkspaceName, {
camera: StateFactory.latest({ x: 0, y: 0, z: 0 }),
});

assert.strictEqual(states.props.camera.presence, presence);
});
});

addControlsTests(createLatestManager);
Expand Down
19 changes: 19 additions & 0 deletions packages/framework/presence/src/test/notificationsManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,5 +487,24 @@ describe("Presence", () => {
// Verify
assert(originalEventHandlerCalled, "originalEventHandler not called");
});

it(".presence provides Presence it was created under", () => {
notificationsWorkspace.add(
"testEvents",
Notifications<
// Below explicit generic specification should not be required.
{
newId: (id: number) => void;
},
"testEvents"
>(
// A default handler is not required
{},
),
);

assert.strictEqual(notificationsWorkspace.props.testEvents.presence, presence);
assert.strictEqual(notificationsWorkspace.presence, presence);
});
});
});
18 changes: 17 additions & 1 deletion packages/framework/presence/src/test/presenceStates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,23 @@
* Licensed under the MIT License.
*/

import { strict as assert } from "node:assert";

import type {
JsonDeserialized,
JsonSerializable,
} from "@fluidframework/core-interfaces/internal/exposedUtilityTypes";

import type { InternalTypes } from "../exposedInternalTypes.js";
import type { Presence } from "../presence.js";
import { createPresenceManager } from "../presenceManager.js";

import { addControlsTests } from "./broadcastControlsTests.js";
import { MockEphemeralRuntime } from "./mockEphemeralRuntime.js";

import { StateFactory } from "@fluidframework/presence/alpha";

const testWorkspaceName = "name:testWorkspaceA";

describe("Presence", () => {
describe("StatesWorkspace", () => {
Expand All @@ -21,7 +29,15 @@ describe("Presence", () => {
it("API use compiles", () => {});

addControlsTests((presence, controlSettings) => {
return presence.states.getWorkspace("name:testWorkspaceA", {}, controlSettings);
return presence.states.getWorkspace(testWorkspaceName, {}, controlSettings);
});

it(".presence provides Presence it was created under", () => {
const presence = createPresenceManager(new MockEphemeralRuntime());
const states = presence.states.getWorkspace(testWorkspaceName, {
obj: StateFactory.latest({}),
});
assert.strictEqual(states.presence, presence);
});
});
});
Expand Down
11 changes: 11 additions & 0 deletions packages/framework/presence/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import type { BroadcastControls } from "./broadcastControls.js";
import type { InternalTypes } from "./exposedInternalTypes.js";
import type { NotificationsManager } from "./notificationsManager.js";
import type { Presence } from "./presence.js";

/**
* Unique address within a session.
Expand Down Expand Up @@ -106,6 +107,11 @@ export interface StatesWorkspace<
* Default controls for management of broadcast updates.
*/
readonly controls: BroadcastControls;

/**
* Containing {@link Presence}
*/
readonly presence: Presence;
}

// #endregion StatesWorkspace
Expand Down Expand Up @@ -161,6 +167,11 @@ export interface NotificationsWorkspace<TSchema extends NotificationsWorkspaceSc
* Registry of `NotificationsManager`s.
*/
readonly props: StatesWorkspaceEntries<TSchema>;

/**
* Containing {@link Presence}
*/
readonly presence: Presence;
}

// #endregion NotificationsWorkspace
Loading