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
7 changes: 7 additions & 0 deletions .changeset/sour-mirrors-wave.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"__section": other
---

Presence APIs have been renamed

Check failure on line 6 in .changeset/sour-mirrors-wave.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Vale.Spelling] Did you really mean 'APIs'? Raw Output: {"message": "[Vale.Spelling] Did you really mean 'APIs'?", "location": {"path": ".changeset/sour-mirrors-wave.md", "range": {"start": {"line": 6, "column": 10}}}, "severity": "ERROR"}

The following API changes have been made to improve clarity and consistency:

Expand Down Expand Up @@ -55,6 +55,13 @@
| `PresenceWorkspaceEntry` | `StatesWorkspaceEntry` |
| `SessionClientStatus` | `AttendeeStatus` |
| `ValueMap` | `StateMap` |
| - | `Latest.presence`|
| - | `LatestMap.presence`|
| - | `Notifications.presence`|
| - | `NotificationsWorkspace.presence`|
| - | `StatesWorkspace.presence`|



> [!NOTE]

Check failure on line 66 in .changeset/sour-mirrors-wave.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [proselint.Annotations] 'NOTE' left in text. Raw Output: {"message": "[proselint.Annotations] 'NOTE' left in text.", "location": {"path": ".changeset/sour-mirrors-wave.md", "range": {"start": {"line": 66, "column": 5}}}, "severity": "ERROR"}
> To fully replace the former `Latest` and `LatestMap` functions, you should import `StateFactory` and call `StateFactory.latest` and `StateFactory.latestMap` respectively. The new `Latest` and `LatestMap` APIs replace `LatestValueManager` and `LatestMapValueManager` respectively.

Check failure on line 67 in .changeset/sour-mirrors-wave.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Vale.Spelling] Did you really mean 'APIs'? Raw Output: {"message": "[Vale.Spelling] Did you really mean 'APIs'?", "location": {"path": ".changeset/sour-mirrors-wave.md", "range": {"start": {"line": 67, "column": 207}}}, "severity": "ERROR"}
7 changes: 6 additions & 1 deletion packages/framework/presence/api-report/presence.alpha.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function getPresenceViaDataObject(fluidLoadable: ExperimentalPresenceDO):
export namespace InternalTypes {
export type ManagerFactory<TKey extends string, TValue extends ValueDirectoryOrState<any>, TManager> = {
instanceBase: new (...args: any[]) => any;
} & ((key: TKey, datastoreHandle: StateDatastoreHandle<TKey, TValue>) => {
} & ((key: TKey, datastoreHandle: StateDatastoreHandle<TKey, TValue>, presence: Presence) => {
initialData?: {
value: TValue;
allowableUpdateLatencyMs: number | undefined;
Expand Down 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
3 changes: 3 additions & 0 deletions packages/framework/presence/src/exposedInternalTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
JsonSerializable,
} from "@fluidframework/core-interfaces/internal/exposedUtilityTypes";

import type { Presence } from "./presence.js";

/**
* Collection of value types that are not intended to be used/imported
* directly outside of this package.
Expand Down Expand Up @@ -111,6 +113,7 @@ export namespace InternalTypes {
> = { instanceBase: new (...args: any[]) => any } & ((
key: TKey,
datastoreHandle: StateDatastoreHandle<TKey, TValue>,
presence: Presence,
) => {
initialData?: { value: TValue; allowableUpdateLatencyMs: number | undefined };
manager: StateValue<TManager>;
Expand Down
10 changes: 9 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 @@ -321,6 +321,11 @@ export interface LatestMap<T, Keys extends string | number = string | number> {
*/
readonly controls: BroadcastControls;

/**
* Root presence object
*/
readonly presence: Presence;

/**
* Current value map for this client.
*/
Expand Down Expand Up @@ -356,6 +361,7 @@ class LatestMapValueManagerImpl<
RegistrationKey,
InternalTypes.MapValueState<T, Keys>
>,
public readonly presence: Presence,
public readonly value: InternalTypes.MapValueState<T, Keys>,
controlSettings: BroadcastControlSettings | undefined,
) {
Expand Down Expand Up @@ -517,6 +523,7 @@ export function latestMap<
RegistrationKey,
InternalTypes.MapValueState<T, Keys>
>,
presence: Presence,
): {
initialData: { value: typeof value; allowableUpdateLatencyMs: number | undefined };
manager: InternalTypes.StateValue<LatestMap<T, Keys>>;
Expand All @@ -530,6 +537,7 @@ export function latestMap<
new LatestMapValueManagerImpl(
key,
datastoreFromHandle(datastoreHandle),
presence,
value,
controls,
),
Expand Down
17 changes: 15 additions & 2 deletions 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 @@ -64,6 +64,11 @@ export interface Latest<T> {
*/
readonly controls: BroadcastControls;

/**
* Root presence object
*/
readonly presence: Presence;

/**
* Current state for this client.
* State for this client that will be transmitted to all other connected clients.
Expand Down Expand Up @@ -97,6 +102,7 @@ class LatestValueManagerImpl<T, Key extends string>
private readonly key: Key,
private readonly datastore: StateDatastore<Key, InternalTypes.ValueRequiredState<T>>,
public readonly value: InternalTypes.ValueRequiredState<T>,
public readonly presence: Presence,
controlSettings: BroadcastControlSettings | undefined,
) {
this.controls = new OptionalBroadcastControl(controlSettings);
Expand Down Expand Up @@ -194,13 +200,20 @@ export function latest<T extends object, Key extends string = string>(
Key,
InternalTypes.ValueRequiredState<T>
>,
presence: Presence,
): {
initialData: { value: typeof value; allowableUpdateLatencyMs: number | undefined };
manager: InternalTypes.StateValue<Latest<T>>;
} => ({
initialData: { value, allowableUpdateLatencyMs: controls?.allowableUpdateLatencyMs },
manager: brandIVM<LatestValueManagerImpl<T, Key>, T, InternalTypes.ValueRequiredState<T>>(
new LatestValueManagerImpl(key, datastoreFromHandle(datastoreHandle), value, controls),
new LatestValueManagerImpl(
key,
datastoreFromHandle(datastoreHandle),
value,
presence,
controls,
),
),
});
return Object.assign(factory, { instanceBase: LatestValueManagerImpl });
Expand Down
10 changes: 9 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 @@ -144,6 +144,11 @@ export interface NotificationsManager<
* Provides subscription to notifications from other clients.
*/
readonly notifications: NotificationListenable<T>;

/**
* Root presence object
*/
readonly presence: Presence;
}

/**
Expand Down Expand Up @@ -205,6 +210,7 @@ class NotificationsManagerImpl<
Key,
InternalTypes.ValueRequiredState<InternalTypes.NotificationType>
>,
public readonly presence: Presence,
initialSubscriptions: Partial<NotificationSubscriptions<T>>,
) {
// Add event listeners provided at instantiation
Expand Down Expand Up @@ -275,6 +281,7 @@ export function Notifications<
Key,
InternalTypes.ValueRequiredState<InternalTypes.NotificationType>
>,
presence: Presence,
): {
manager: InternalTypes.StateValue<NotificationsManager<T>>;
} => ({
Expand All @@ -286,6 +293,7 @@ export function Notifications<
new NotificationsManagerImpl(
key,
datastoreFromHandle(datastoreHandle),
presence,
initialSubscriptions,
),
),
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 @@ -226,6 +227,7 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager {
workspaceDatastore,
requestedContent,
controls,
this.presence,
);

this.workspaces.set(internalWorkspaceAddress, entry);
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
16 changes: 12 additions & 4 deletions 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 @@ -258,6 +258,7 @@ class PresenceStatesImpl<TSchema extends StatesWorkspaceSchema>
public constructor(
private readonly runtime: PresenceRuntime,
private readonly datastore: ValueElementMap<TSchema>,
public readonly presence: Presence,
initialContent: TSchema,
controlsSettings: BroadcastControlSettings | undefined,
) {
Expand All @@ -276,7 +277,7 @@ class PresenceStatesImpl<TSchema extends StatesWorkspaceSchema>
const newValues: { [key: string]: InternalTypes.ValueDirectoryOrState<unknown> } = {};
let cumulativeAllowableUpdateLatencyMs: number | undefined;
for (const [key, nodeFactory] of Object.entries(initialContent)) {
const newNodeData = nodeFactory(key, handleFromDatastore(this));
const newNodeData = nodeFactory(key, handleFromDatastore(this), this.presence);
nodes[key as keyof TSchema] = newNodeData.manager;
if ("initialData" in newNodeData) {
const { value, allowableUpdateLatencyMs } = newNodeData.initialData;
Expand Down Expand Up @@ -361,7 +362,7 @@ class PresenceStatesImpl<TSchema extends StatesWorkspaceSchema>
TSchema & Record<TKey, InternalTypes.ManagerFactory<TKey, TValue, TValueManager>>
> {
assert(!(key in this.nodes), 0xa3c /* Already have entry for key in map */);
const nodeData = nodeFactory(key, handleFromDatastore(this));
const nodeData = nodeFactory(key, handleFromDatastore(this), this.presence);
this.nodes[key] = nodeData.manager;
if ("initialData" in nodeData) {
const { value, allowableUpdateLatencyMs } = nodeData.initialData;
Expand Down Expand Up @@ -436,8 +437,15 @@ export function createPresenceStates<TSchema extends StatesWorkspaceSchema>(
datastore: ValueElementMap<StatesWorkspaceSchema>,
initialContent: TSchema,
controls: BroadcastControlSettings | undefined,
presence: Presence,
): { public: StatesWorkspace<TSchema>; internal: PresenceStatesInternal } {
const impl = new PresenceStatesImpl<TSchema>(runtime, datastore, initialContent, controls);
const impl = new PresenceStatesImpl<TSchema>(
runtime,
datastore,
presence,
initialContent,
controls,
);

return {
public: impl,
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("can acces root presence object", () => {
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("can access root presence object", () => {
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("can access root presence object", () => {
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);
});
});
});
Loading
Loading