Skip to content

Commit

Permalink
feat: add a refresh for presence (#2916)
Browse files Browse the repository at this point in the history
Add an on by default refresh to presence on mgt-person
Adds a PresenceService to poll for presence and push presence to connected components via a pub/sub mechanism
Adds stories to show the new attributes on the mgt-person component.
  • Loading branch information
plasne authored Jan 19, 2024
1 parent c9c4047 commit 040beb6
Show file tree
Hide file tree
Showing 12 changed files with 517 additions and 33 deletions.
2 changes: 1 addition & 1 deletion packages/mgt-chat/src/components/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ export const Chat = ({ chatId }: IMgtChatProps) => {
<Person
userId={userId}
avatarSize="small"
showPresence={true}
personCardInteraction={PersonCardInteraction.hover}
showPresence={true}
/>
);
}}
Expand Down
110 changes: 109 additions & 1 deletion packages/mgt-components/src/components/mgt-person/mgt-person.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
import { fixture, html, expect, waitUntil } from '@open-wc/testing';
import { MockProvider, Providers } from '@microsoft/mgt-element';
import { registerMgtPersonComponent } from './mgt-person';
import { useConfig } from '../../graph/graph.presence.mock';
import { PresenceService } from '../../utils/PresenceService';

describe('mgt-person - tests', () => {
before(() => {
registerMgtPersonComponent();
Providers.globalProvider = new MockProvider(true);
Providers.globalProvider = new MockProvider(true, [{ id: '48d31887-5fad-4d73-a9f5-3c356e68a038' }]);
});

it('should render', async () => {
Expand All @@ -31,6 +33,19 @@ describe('mgt-person - tests', () => {
);
});

it('unknown user should render with a default icon', async () => {
// purposely throws browser error "Error: Invalid userId"
const person = await fixture(
html`<mgt-person user-id="2004BC77-F054-4678-8883-768ADA7B00EC" view="twoLines"></mgt-person>`
);
await waitUntil(() => person.shadowRoot.querySelector('svg'), 'no svg was populated');
await expect(person).shadowDom.to.equal(
`<i class="avatar-icon" icon="no-data">
<svg />
</i>`
);
});

it('should pop up a flyout on click', async () => {
const person = await fixture(html`<mgt-person person-query="me" view="twoLines" person-card="click"></mgt-person>`);
await waitUntil(() => person.shadowRoot.querySelector('img'), 'mgt-person did not update');
Expand Down Expand Up @@ -176,4 +191,97 @@ describe('mgt-person - tests', () => {
})}' view="twoLines"></mgt-person>`);
await expect(person.shadowRoot.querySelector('span.initials')).lightDom.to.equal('FV');
});

it('should support a change in presence', async () => {
useConfig({
default: 'Available',
3: 'Busy',
4: 'Busy',
5: 'Busy'
});

PresenceService.config.initial = 100;
PresenceService.config.refresh = 200;

const person = await fixture(
html`<mgt-person person-query="me" show-presence="true" view="twoLines" iteration="0"></mgt-person>`
);

const match = (status: string) => `<div class=" person-root twolines " dir="ltr">
<div class="avatar-wrapper">
<img alt="Photo for Megan Bowen" src="">
<span
aria-label="${status}"
class="presence-wrapper"
role="img"
title="${status}"
>
</span>
</div>
<div class=" details-wrapper ">
<div class="line1" role="presentation" aria-label="Megan Bowen">Megan Bowen</div>
<div class="line2" role="presentation" aria-label="Auditor">Auditor</div>
</div>
</div>`;

// starts as Offline
await waitUntil(() => person.shadowRoot.querySelector('span.presence-wrapper'), 'no presence span');
await expect(person).shadowDom.to.equal(match('Offline'), { ignoreAttributes: ['src'] });

// changes to Available on initial
await waitUntil(() => person.shadowRoot.querySelector('span[title="Available"]'), 'did not update to available', {
timeout: 4000
});
await expect(person).shadowDom.to.equal(match('Available'), { ignoreAttributes: ['src'] });

// changes to Busy on refresh
await waitUntil(() => person.shadowRoot.querySelector('span[title="Busy"]'), 'did not update to busy', {
timeout: 4000
});
await expect(person).shadowDom.to.equal(match('Busy'), { ignoreAttributes: ['src'] });
});

it('should not update presence on error', async () => {
useConfig({
default: 'Available',
0: new Error('purposeful error'),
1: new Error('purposeful error'),
2: new Error('purposeful error')
});

PresenceService.config.initial = 1000;
PresenceService.config.refresh = 2000;

const person = await fixture(
html`<mgt-person person-query="me" show-presence="true" view="twoLines" iteration="0"></mgt-person>`
);
await waitUntil(() => person.shadowRoot.querySelector('img'), 'mgt-person did not update');

const match = (status: string) => `<div class=" person-root twolines " dir="ltr">
<div class="avatar-wrapper">
<img alt="Photo for Megan Bowen" src="">
<span
aria-label="${status}"
class="presence-wrapper"
role="img"
title="${status}"
>
</span>
</div>
<div class=" details-wrapper ">
<div class="line1" role="presentation" aria-label="Megan Bowen">Megan Bowen</div>
<div class="line2" role="presentation" aria-label="Auditor">Auditor</div>
</div>
</div>`;

// starts Offline during errors
await waitUntil(() => person.shadowRoot.querySelector('span.presence-wrapper'), 'no presence span');
await expect(person).shadowDom.to.equal(match('Offline'), { ignoreAttributes: ['src'] });

// changes to Available eventually
await waitUntil(() => person.shadowRoot.querySelector('span[title="Available"]'), 'did not update to available', {
timeout: 4000
});
await expect(person).shadowDom.to.equal(match('Available'), { ignoreAttributes: ['src'] });
});
});
87 changes: 81 additions & 6 deletions packages/mgt-components/src/components/mgt-person/mgt-person.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { isUser, isContact } from '../../graph/entityType';
import { ifDefined } from 'lit/directives/if-defined.js';
import { buildComponentName, registerComponent } from '@microsoft/mgt-element';
import { IExpandable, IHistoryClearer } from '../mgt-person-card/types';
import { PresenceService, PresenceAwareComponent } from '../../utils/PresenceService';

export { PersonCardInteraction } from '../PersonCardInteraction';

Expand Down Expand Up @@ -107,7 +108,7 @@ export const registerMgtPersonComponent = () => {
*
* @cssprop --person-details-wrapper-width - {Length} the minimum width of the details section. Default is 168px.
*/
export class MgtPerson extends MgtTemplatedComponent {
export class MgtPerson extends MgtTemplatedComponent implements PresenceAwareComponent {
/**
* Array of styles to apply to the element. The styles should be defined
* using the `css` tag function.
Expand Down Expand Up @@ -240,6 +241,17 @@ export class MgtPerson extends MgtTemplatedComponent {
})
public showPresence: boolean;

/**
* determines if person component refreshes presence
*
* @type {boolean}
*/
@property({
attribute: 'disable-presence-refresh',
type: Boolean
})
public disablePresenceRefresh;

/**
* determines person component avatar size and apply presence badge accordingly.
* Default is "auto". When you set the view > 1, it will default to "auto".
Expand Down Expand Up @@ -563,6 +575,19 @@ export class MgtPerson extends MgtTemplatedComponent {
this.verticalLayout = false;
}

/**
* Unregisters from presence service if necessary. Note, it does not cause an error
* if the component was not registered.
*
* @memberof MgtAgenda
*/
public disconnectedCallback() {
if (this.showPresence && !this.disablePresenceRefresh) {
PresenceService.unregister(this);
}
super.disconnectedCallback();
}

/**
* Invoked on each update to perform rendering tasks. This method must return
* a lit-html TemplateResult. Setting properties inside this method will *not*
Expand Down Expand Up @@ -639,7 +664,22 @@ export class MgtPerson extends MgtTemplatedComponent {
* @memberof MgtPerson
*/
protected renderLoading(): TemplateResult {
return this.renderTemplate('loading', null) || html``;
const loadingTemplate = this.renderTemplate('loading', null);
if (loadingTemplate) {
return loadingTemplate;
}

const avatarClasses = {
'avatar-icon': true,
vertical: this.isVertical(),
small: !this.isLargeAvatar(),
threeLines: this.isThreeLines(),
fourLines: this.isFourLines()
};

return html`
<i class=${classMap(avatarClasses)} icon='loading'>${this.renderLoadingIcon()}</i>
`;
}

/**
Expand Down Expand Up @@ -677,8 +717,19 @@ export class MgtPerson extends MgtTemplatedComponent {
};

return html`
<i class=${classMap(avatarClasses)}></i>
`;
<i class=${classMap(avatarClasses)} icon='no-data'>${this.renderPersonIcon()}</i>
`;
}

/**
* Render a loading icon.
*
* @protected
* @returns
* @memberof MgtPerson
*/
protected renderLoadingIcon() {
return getSvg(SvgIcon.Loading);
}

/**
Expand Down Expand Up @@ -1167,14 +1218,16 @@ export class MgtPerson extends MgtTemplatedComponent {

details = this.personDetailsInternal || this.personDetails || this.fallbackDetails;

// populate presence
const defaultPresence: Presence = {
activity: 'Offline',
availability: 'Offline',
id: null
};

if (this.showPresence && !this.personPresence && !this._fetchedPresence) {
if (this.showPresence && !this.disablePresenceRefresh) {
this._fetchedPresence = defaultPresence;
PresenceService.register(this);
} else if (this.showPresence && !this.personPresence && !this._fetchedPresence) {
try {
if (details) {
// setting userId to 'me' ensures only the presence.read permission is required
Expand Down Expand Up @@ -1375,4 +1428,26 @@ export class MgtPerson extends MgtTemplatedComponent {
flyout.open();
}
};

/**
* gets the id of the person that presence updates are needed for
*
* @memberof MgtPerson
* @implements {PresenceAwareComponent}
* @returns {string | undefined}
**/
public get presenceId(): string | undefined {
return this.personDetailsInternal?.id || this.personDetails?.id || this.fallbackDetails?.id;
}

/**
* fires when the presence for the user is changed
*
* @memberof MgtPerson
* @implements {PresenceAwareComponent}
* @param {Presence} [presence]
**/
public onPresenceChange(presence: Presence): void {
this.personPresence = presence;
}
}
1 change: 1 addition & 0 deletions packages/mgt-components/src/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './components/preview';
export * from './graph/types';
export * from './graph/graph.userWithPhoto';
export * from './graph/graph.photos';
export * from './graph/graph.presence';
export * from './graph/cacheStores';
export * from './styles/theme-manager';
export * from './graph/entityType';
Expand Down
55 changes: 55 additions & 0 deletions packages/mgt-components/src/graph/graph.presence.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* -------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
* See License in the project root for license information.
* -------------------------------------------------------------------------------------------
*/

import { IGraph } from '@microsoft/mgt-element';
import { Presence } from '@microsoft/microsoft-graph-types';
import { IDynamicPerson } from './types';

const globalObject = typeof window !== 'undefined' ? window : global;

type status = 'Available' | 'Busy' | 'Away' | 'DoNotDisturb' | Error;

type CallConfig = {
default: status;
[key: string]: status;
} & {
calls?: number;
};

export const useConfig = (config: CallConfig | status = 'Available') => {
if (typeof config === 'object') {
globalObject['unit-test:presence-config'] = config;
} else if (typeof config === 'string') {
globalObject['unit-test:presence-config'] = { default: config as status };
} else {
throw new Error('Invalid config');
}
};

export const getUserPresence = async (_graph: IGraph, _userId?: string): Promise<Presence> => {
return Promise.reject(new Error());
};

export const getUsersPresenceByPeople = async (graph: IGraph, people?: IDynamicPerson[], _ = true) => {
const config = (globalObject['unit-test:presence-config'] as CallConfig) || { default: 'Available' };
const index = config.calls || 0;
const value = config[index] || config.default;
config.calls = index + 1;
if (value instanceof Error) {
return Promise.reject(value);
} else {
const peoplePresence: Record<string, Presence> = {};
for (const person of people || []) {
peoplePresence[person.id] = {
id: person.id,
availability: value,
activity: value
};
}
return Promise.resolve(peoplePresence);
}
};
14 changes: 10 additions & 4 deletions packages/mgt-components/src/graph/graph.presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,13 @@ export const getUserPresence = async (graph: IGraph, userId?: string): Promise<P
};

/**
* async promise, allows developer to get person presense by providing array of IDynamicPerson
* Async promise, allows developer to get person presense by providing array of IDynamicPerson.
* BypassCacheRead forces all presence to be queried from the graph but will still update the cache.
*
* @returns {}
* @memberof BetaGraph
*/
export const getUsersPresenceByPeople = async (graph: IGraph, people?: IDynamicPerson[]) => {
export const getUsersPresenceByPeople = async (graph: IGraph, people?: IDynamicPerson[], bypassCacheRead = false) => {
if (!people || people.length === 0) {
return {};
}
Expand All @@ -90,10 +91,15 @@ export const getUsersPresenceByPeople = async (graph: IGraph, people?: IDynamicP
const id = person.id;
peoplePresence[id] = null;
let presence: CachePresence;
if (getIsPresenceCacheEnabled()) {
if (!bypassCacheRead && getIsPresenceCacheEnabled()) {
presence = await cache.getValue(id);
}
if (getIsPresenceCacheEnabled() && presence && getPresenceInvalidationTime() > Date.now() - presence.timeCached) {
if (
!bypassCacheRead &&
getIsPresenceCacheEnabled() &&
presence &&
getPresenceInvalidationTime() > Date.now() - presence.timeCached
) {
peoplePresence[id] = JSON.parse(presence.presence) as Presence;
} else {
peoplePresenceToQuery.push(id);
Expand Down
Loading

0 comments on commit 040beb6

Please sign in to comment.