Skip to content
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

Implement a refresh for presence #2916

Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const OneToOneChatHeader = ({ chat, currentUserId }: ChatHeaderProps) =>
avatarSize="small"
personCardInteraction={PersonCardInteraction.hover}
showPresence={true}
fetchImage={true}
/>
) : null;
};
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
Loading