MOBILE-4261 user: Use initials instead of default avatar when possible

main
Pau Ferrer Ocaña 2023-06-12 10:48:41 +02:00
parent 28d744f337
commit c3e2cdf731
23 changed files with 217 additions and 120 deletions

View File

@ -1,6 +1,8 @@
@import "~theme/globals";
:host {
--core-avatar-size: 30px;
:host .core-block-content ::ng-deep {
.core-block-content ::ng-deep {
max-height: 200px;
overflow-y: auto;
.item-inner,
@ -25,11 +27,29 @@
.core-adapted-img-container {
display: inline;
@include margin-horizontal(0px, 8px);
@include margin-horizontal(0px, 0.25rem);
}
.userpicture {
border-radius: 50%;
border-radius: var(--core-avatar-radius);
width: var(--core-avatar-size);
height: var(--core-avatar-size);
}
.userinitials {
background-color: var(--gray-200);
vertical-align: middle;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--core-avatar-radius);
color: var(--gray-800);
font-weight: normal;
width: var(--core-avatar-size);
height: var(--core-avatar-size);
font-size: 0.7rem;
margin-right: 0.25rem;
text-decoration: none;
}
}
@ -48,4 +68,5 @@
text-align: center;
}
}
}

View File

@ -39,10 +39,8 @@
<span *ngIf="action == 'timeadded'">{{ entry.timecreated * 1000 | coreFormatDate }}</span>
<span *ngIf="action == 'timemodified'">{{ entry.timemodified * 1000 | coreFormatDate }}</span>
<a *ngIf="action == 'userpicture'" core-user-link [courseId]="database.course" [userId]="entry.userid" [title]="entry.fullname">
<img class="avatar-round" [src]="userPicture" [alt]="'core.pictureof' | translate:{$a: entry.fullname}" core-external-content
onError="this.src='assets/img/user-avatar.png'">
</a>
<core-user-avatar *ngIf="action == 'userpicture'" [user]="entry" slot="start" [courseId]="database.course" [userId]="entry.userid"
[profileUrl]="userPicture"></core-user-avatar>
<a *ngIf="action == 'user' && entry" core-user-link [courseId]="database.course" [userId]="entry.userid" [title]="entry.fullname">
{{entry.fullname}}

View File

@ -63,7 +63,7 @@ export class CoreSitePickerComponent implements OnInit {
site.fullNameAndSiteName = Translate.instant(
'core.fullnameandsitename',
{ fullname: site.fullName, sitename: siteName },
{ fullname: site.fullname, sitename: siteName },
);
}));

View File

@ -1,14 +1,26 @@
<img *ngIf="avatarUrl && linkProfile" [src]="avatarUrl" [alt]="'core.pictureof' | translate:{$a: fullname}" core-external-content
onError="this.src='assets/img/user-avatar.png'" (ariaButtonClick)="gotoProfile($event)">
<ng-container *ngIf="avatarUrl">
<img *ngIf="linkProfile" [src]="avatarUrl" [alt]="'core.pictureof' | translate:{$a: fullname}" core-external-content
(error)="loadImageError()" (ariaButtonClick)="gotoProfile($event)" [siteId]="siteId">
<img *ngIf="avatarUrl && !linkProfile" [src]="avatarUrl" [alt]="'core.pictureof' | translate:{$a: fullname}" core-external-content
onError="this.src='assets/img/user-avatar.png'" aria-hidden="true">
<img *ngIf="!linkProfile" [src]="avatarUrl" [alt]="'core.pictureof' | translate:{$a: fullname}" core-external-content
(error)="loadImageError()" aria-hidden="true" [siteId]="siteId">
</ng-container>
<ng-container *ngIf="!avatarUrl && initials">
<div class="userinitials" *ngIf="linkProfile" [attr.aria-label]="'core.pictureof' | translate:{$a: fullname}"
(ariaButtonClick)="gotoProfile($event)">
{{ initials }}
</div>
<img *ngIf="!avatarUrl && linkProfile" src="assets/img/user-avatar.png" [alt]="'core.pictureof' | translate:{$a: fullname}"
<div class="userinitials" *ngIf="!linkProfile" [attr.aria-label]="'core.pictureof' | translate:{$a: fullname}" aria-hidden="true">
{{ initials }}
</div>
</ng-container>
<ng-container *ngIf="!avatarUrl && !initials">
<img *ngIf="linkProfile" src="assets/img/user-avatar.png" [alt]="'core.pictureof' | translate:{$a: fullname}"
(ariaButtonClick)="gotoProfile($event)">
<img *ngIf="!avatarUrl && !linkProfile" src="assets/img/user-avatar.png" [alt]="'core.pictureof' | translate:{$a: fullname}"
aria-hidden="true">
<img *ngIf="!linkProfile" src="assets/img/user-avatar.png" [alt]="'core.pictureof' | translate:{$a: fullname}" aria-hidden="true">
</ng-container>
<span *ngIf="checkOnline && isOnline()" class="contact-status online" role="status" [attr.aria-label]="'core.online' | translate">
</span>

View File

@ -6,7 +6,7 @@
height: var(--core-avatar-size);
img {
border-radius: 50%;
border-radius: var(--core-avatar-radius);
width: var(--core-avatar-size);
height: var(--core-avatar-size);
max-width: var(--core-avatar-size);
@ -23,7 +23,7 @@
display: inline-block;
position: relative;
&:after {
border-radius: 50%;
border-radius: var(--core-avatar-radius);
display: block;
position: absolute;
top: 0;
@ -62,6 +62,24 @@
background-color: var(--core-online-color);
}
}
.userinitials {
background-color: var(--gray-200);
vertical-align: middle;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--core-avatar-radius);
color: var(--gray-800);
font-weight: normal;
width: var(--core-avatar-size);
height: var(--core-avatar-size);
font-size: calc(var(--core-avatar-size)*0.3);
}
&.large-avatar .userinitials {
margin-top: 8px;
}
}
:host-context(.toolbar) .contact-status {

View File

@ -20,6 +20,8 @@ import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { USER_PROFILE_PICTURE_UPDATED, CoreUserBasicData } from '@features/user/services/user';
import { CoreNavigator } from '@services/navigator';
import { CoreNetwork } from '@services/network';
import { CoreUrl } from '@singletons/url';
import { CoreUserHelper } from '@features/user/services/user-helper';
/**
* Component to display a "user avatar".
@ -41,8 +43,10 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy {
@Input() userId?: number; // If provided or found it will be used to link the image to the profile.
@Input() courseId?: number;
@Input() checkOnline = false; // If want to check and show online status.
@Input() siteId?: string;
avatarUrl?: string;
initials = '';
// Variable to check if we consider this user online or not.
// @todo Use setting when available (see MDL-63972) so we can use site setting.
@ -56,7 +60,7 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy {
this.pictureObserver = CoreEvents.on(
USER_PROFILE_PICTURE_UPDATED,
(data) => {
if (data.userId == this.userId) {
if (data.userId === this.userId) {
this.avatarUrl = data.picture;
}
},
@ -68,6 +72,8 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy {
* @inheritdoc
*/
ngOnInit(): void {
this.siteId = this.siteId || CoreSites.getCurrentSiteId();
this.setFields();
}
@ -81,6 +87,13 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy {
}
}
/**
* Avatar image loading error handler.
*/
loadImageError(): void {
this.avatarUrl = undefined;
}
/**
* Set fields from user.
*/
@ -88,12 +101,20 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy {
const profileUrl = this.profileUrl || (this.user && (this.user.profileimageurl || this.user.userprofileimageurl ||
this.user.userpictureurl || this.user.profileimageurlsmall || (this.user.urls && this.user.urls.profileimage)));
if (typeof profileUrl == 'string') {
if (typeof profileUrl === 'string') {
this.avatarUrl = profileUrl;
}
this.fullname = this.fullname || (this.user && (this.user.fullname || this.user.userfullname));
if (this.user) {
this.initials = CoreUserHelper.getUserInitials(this.user);
}
if (this.initials && this.avatarUrl && CoreUrl.parse(this.avatarUrl)?.path?.startsWith('/theme/image.php')) {
this.avatarUrl = undefined;
}
this.userId = this.userId || (this.user && (this.user.userid || this.user.id));
this.courseId = this.courseId || (this.user && this.user.courseid);
}

View File

@ -20,12 +20,10 @@
</ion-label>
</ion-item>
<ion-item *ngFor="let site of sites" (click)="siteClicked(site.id)" detail="false" button>
<ion-avatar slot="start">
<img [src]="site.avatar" core-external-content [siteId]="site.id"
alt="{{ 'core.pictureof' | translate:{$a: site.fullName} }}" onError="this.src='assets/img/user-avatar.png'">
</ion-avatar>
<core-user-avatar [user]="site" slot="start" [linkProfile]="false"></core-user-avatar>
<ion-label>
<p class="item-heading">{{site.fullName}}</p>
<p class="item-heading">{{site.fullname}}</p>
<p>
<core-format-text [text]="site.siteName" clean="true" [siteId]="site.id"></core-format-text>
</p>

View File

@ -35,13 +35,11 @@
</ion-item-divider>
<ion-item detail="false">
<ion-avatar slot="start">
<img [src]="accountsList.currentSite.avatar" core-external-content [siteId]="accountsList.currentSite.id"
alt="{{ 'core.pictureof' | translate:{$a: accountsList.currentSite.fullName} }}"
onError="this.src='assets/img/user-avatar.png'">
</ion-avatar>
<core-user-avatar [user]="accountsList.currentSite" slot="start" [linkProfile]="false"
[siteId]="accountsList.currentSite.id"></core-user-avatar>
<ion-label>
<p class="item-heading">{{accountsList.currentSite.fullName}}</p>
<p class="item-heading">{{accountsList.currentSite.fullname}}</p>
</ion-label>
<ion-icon color="success" name="fas-check"></ion-icon>
</ion-item>
@ -75,12 +73,10 @@
<!-- Template to render a list of sites. -->
<ng-template #siteList let-sites="sites">
<ion-item button *ngFor="let site of sites" (click)="login($event, site.id)" detail="true">
<ion-avatar slot="start">
<img [src]="site.avatar" core-external-content [siteId]="site.id" alt="{{ 'core.pictureof' | translate:{$a: site.fullName} }}"
onError="this.src='assets/img/user-avatar.png'">
</ion-avatar>
<core-user-avatar [user]="site" slot="start" [linkProfile]="false" [siteId]="site.id"></core-user-avatar>
<ion-label>
<p class="item-heading">{{site.fullName}}</p>
<p class="item-heading">{{site.fullname}}</p>
</ion-label>
<ion-badge slot="end" *ngIf="!showDelete && site.badge" @coreShowHideAnimation>
<span aria-hidden="true">{{site.badge}}</span>

View File

@ -75,6 +75,10 @@
text-decoration: underline;
}
core-user-avatar.large-avatar {
--core-avatar-size: var(--core-large-avatar-size);
}
@if ($core-login-hide-forgot-password) {
.core-login-forgotten-password {
display: none;

View File

@ -20,8 +20,8 @@
<div class="list-item-limited-width">
<div class="ion-text-wrap ion-text-center ion-margin-bottom" [ngClass]="{'item-avatar-center': showUserAvatar}">
<!-- Show user avatar. -->
<img *ngIf="showUserAvatar" [src]="userAvatar" class="large-avatar" core-external-content [siteId]="siteId"
alt="{{ 'core.pictureof' | translate:{$a: userFullName} }}" onError="this.src='assets/img/user-avatar.png'">
<core-user-avatar class="large-avatar" *ngIf="showUserAvatar" [user]="siteInfo" [linkProfile]="false"
[siteId]="siteId"></core-user-avatar>
<div class="core-login-site-logo" *ngIf="!showUserAvatar">
<!-- Show site logo or a default image. -->
@ -29,8 +29,8 @@
<img *ngIf="!logoUrl" src="assets/img/login_logo.png" role="presentation" alt="">
</div>
<p *ngIf="siteName" class="ion-padding core-sitename">
<core-format-text [text]="siteName" [filter]="false"></core-format-text>
<p *ngIf="siteInfo?.siteName" class="ion-padding core-sitename">
<core-format-text [text]="siteInfo?.siteName" [filter]="false"></core-format-text>
</p>
<p class="core-siteurl">{{siteUrl}}</p>

View File

@ -17,7 +17,7 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { CoreApp } from '@services/app';
import { CoreNetwork } from '@services/network';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreSiteBasicInfo, CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { CoreLoginHelper } from '@features/login/services/login-helper';
@ -47,9 +47,6 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
credForm: FormGroup;
siteUrl!: string;
username!: string;
userFullName!: string;
userAvatar?: string;
siteName!: string;
logoUrl?: string;
identityProviders?: CoreSiteIdentityProvider[];
showForgottenPassword = true;
@ -58,6 +55,7 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
isOAuth = false;
isLoggedOut: boolean;
siteId!: string;
siteInfo?: CoreSiteBasicInfo;
showScanQR = false;
showLoading = true;
reconnectAttempts = 0;
@ -104,20 +102,30 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
throw new CoreError('Invalid site');
}
this.siteUrl = site.getURL();
this.siteInfo = {
id: this.siteId,
siteUrl: this.siteUrl,
siteUrlWithoutProtocol: this.siteUrl.replace(/^https?:\/\//, '').toLowerCase(),
fullname: site.infos.fullname,
firstname: site.infos.firstname,
lastname: site.infos.lastname,
siteName: await site.getSiteName(),
userpictureurl: site.infos.userpictureurl,
loggedOut: true, // Not used.
};
this.username = site.infos.username;
this.userFullName = site.infos.fullname;
this.userAvatar = site.infos.userpictureurl;
this.siteUrl = site.infos.siteurl;
this.siteName = await site.getSiteName();
this.supportConfig = new CoreUserAuthenticatedSupportConfig(site);
// If login was OAuth we should only reach this page if the OAuth method ID has changed.
this.isOAuth = site.isOAuth();
const sites = await CoreLoginHelper.getAvailableSites();
const availableSites = await CoreLoginHelper.getAvailableSites();
// Show logo instead of avatar if it's a fixed site.
this.showUserAvatar = !!this.userAvatar && !sites.length;
this.showUserAvatar = !availableSites.length;
this.checkSiteConfig(site);
@ -130,7 +138,7 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
}
/**
* Component destroyed.
* @inheritdoc
*/
ngOnDestroy(): void {
this.viewLeft = true;
@ -191,10 +199,6 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
await CoreSites.checkApplication(this.siteConfig);
// Check logoURL if user avatar is not set.
if (this.userAvatar?.startsWith(this.siteUrl + '/theme/image.php')) {
this.showUserAvatar = false;
}
this.logoUrl = CoreLoginHelper.getLogoUrl(this.siteConfig);
}

View File

@ -33,12 +33,10 @@
</ion-item-divider>
<ion-item button *ngFor="let site of sites" (click)="login($event, site.id)" detail="true">
<ion-avatar slot="start">
<img [src]="site.avatar" core-external-content [siteId]="site.id"
alt="{{ 'core.pictureof' | translate:{$a: site.fullName} }}" onError="this.src='assets/img/user-avatar.png'">
</ion-avatar>
<core-user-avatar [user]="site" slot="start" [linkProfile]="false" [siteId]="site.id"></core-user-avatar>
<ion-label>
<p class="item-heading">{{site.fullName}}</p>
<p class="item-heading">{{site.fullname}}</p>
</ion-label>
<ion-badge slot="end" *ngIf="!showDelete && site.badge" @coreShowHideAnimation>
<span aria-hidden="true">{{site.badge}}</span>

View File

@ -69,12 +69,10 @@
<!-- Template to render a site space usage. -->
<ng-template #siteUsage let-site="site">
<ion-avatar slot="start">
<img [src]="site.avatar" core-external-content [siteId]="site.id" alt="{{ 'core.pictureof' | translate:{$a: site.fullName} }}"
onError="this.src='assets/img/user-avatar.png'">
</ion-avatar>
<core-user-avatar [user]="site" slot="start" [linkProfile]="false" [siteId]="site.id"></core-user-avatar>
<ion-label class="ion-text-wrap">
<p class="item-heading">{{site.fullName}}</p>
<p class="item-heading">{{site.fullname}}</p>
<ion-badge color="light" *ngIf="site.spaceUsage !== undefined">{{ site.spaceUsage | coreBytesToSize }}</ion-badge>
</ion-label>
<ion-button fill="clear" color="danger" slot="end" (click)="deleteSiteStorage(site)"

View File

@ -70,7 +70,7 @@ export class CoreSettingsSpaceUsagePage implements OnInit, OnDestroy {
if (siteInfo) {
siteEntry.siteUrl = siteInfo.siteurl;
siteEntry.fullName = siteInfo.fullname;
siteEntry.fullname = siteInfo.fullname;
}
});
}

View File

@ -94,12 +94,10 @@
<!-- Template to render a site to sync. -->
<ng-template #siteSync let-site="site">
<ion-avatar slot="start">
<img [src]="site.avatar" core-external-content [siteId]="site.id" alt="{{ 'core.pictureof' | translate:{$a: site.fullName} }}"
onError="this.src='assets/img/user-avatar.png'">
</ion-avatar>
<core-user-avatar [user]="site" slot="start" [linkProfile]="false" [siteId]="site.id"></core-user-avatar>
<ion-label>
<p class="item-heading">{{site.fullName}}</p>
<p class="item-heading">{{site.fullname}}</p>
<p class="text-danger" *ngIf="site.loggedOut">{{ 'core.settings.logintosync' | translate }}</p>
</ion-label>
<core-button-with-spinner [loading]="isSynchronizing(site.id)" slot="end" *ngIf="!site.loggedOut">

View File

@ -84,7 +84,7 @@ export class CoreSettingsSynchronizationPage implements OnInit, OnDestroy {
if (siteInfo) {
siteEntry.siteUrl = siteInfo.siteurl;
siteEntry.fullName = siteInfo.fullname;
siteEntry.fullname = siteInfo.fullname;
}
});

View File

@ -18,12 +18,10 @@
</ion-label>
</ion-item>
<ion-item *ngFor="let site of sites" (click)="storeInSite(site.id)" detail="false" button>
<ion-avatar slot="start" aria-hidden="true">
<img [src]="site.avatar" core-external-content [siteId]="site.id"
alt="{{ 'core.pictureof' | translate:{$a: site.fullname} }}" onError="this.src='assets/img/user-avatar.png'">
</ion-avatar>
<core-user-avatar [user]="site" slot="start" [linkProfile]="false" [siteId]="site.id"></core-user-avatar>
<ion-label>
<p class="item-heading">{{site.fullName}}</p>
<p class="item-heading">{{site.fullname}}</p>
<p>
<core-format-text clean="true" [text]="site.siteName" [siteId]="site.id"></core-format-text>
</p>

View File

@ -1,8 +1,7 @@
<ion-card *ngFor="let item of items">
<ion-item class="ion-text-wrap" [href]="item.url" core-link [capture]="true">
<ion-avatar slot="start" *ngIf="item.avatarUrl">
<img [src]="item.avatarUrl" core-external-content alt="" role="presentation" onError="this.src='assets/img/user-avatar.png'">
</ion-avatar>
<core-user-avatar *ngIf="item.avatarUrl" [profileUrl]="item.avatarUrl" slot="start" [linkProfile]="false"></core-user-avatar>
<core-mod-icon *ngIf="item.iconUrl" [modicon]="item.iconUrl" slot="start" [showAlt]="false">
</core-mod-icon>
<ion-label>

View File

@ -17,7 +17,7 @@ import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites';
import { makeSingleton, Translate } from '@singletons';
import { CoreUserRole } from './user';
import { CoreUserProfile, CoreUserRole } from './user';
/**
* Service that provides some features regarding users information.
@ -83,6 +83,21 @@ export class CoreUserHelperProvider {
await CoreNavigator.navigate('/user/completeprofile', { params: { siteId }, reset: true });
}
/**
* Get the user initials.
*
* @param user User object.
* @returns Promise resolved with the user data.
*/
getUserInitials(user: Partial<CoreUserProfile>): string {
if (!user.firstname && !user.lastname) {
// @TODO: Use local info or check WS to get initials from.
return '';
}
return (user.firstname?.charAt(0) || '') + (user.lastname?.charAt(0) || '');
}
}
export const CoreUserHelper = makeSingleton(CoreUserHelperProvider);

View File

@ -27,6 +27,8 @@ import { CoreStatusWithWarningsWSResponse, CoreWSExternalWarning } from '@servic
import { CoreError } from '@classes/errors/error';
import { USERS_TABLE_NAME, CoreUserDBRecord } from './database/user';
import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications';
import { CoreUserHelper } from './user-helper';
import { CoreUrl } from '@singletons/url';
const ROOT_CACHE_KEY = 'mmUser:';
@ -673,6 +675,14 @@ export class CoreUserProvider {
return;
}
// Do not prefetch when initials are set and image is default.
if ('firstname' in entry || 'lastname' in entry) {
const initials = CoreUserHelper.getUserInitials(entry);
if (initials && imageUrl && CoreUrl.parse(imageUrl)?.path === '/theme/image.php') {
return;
}
}
treated[imageUrl] = true;
try {

View File

@ -1260,9 +1260,11 @@ export class CoreSitesProvider {
id: site.id,
siteUrl: site.siteUrl,
siteUrlWithoutProtocol: site.siteUrl.replace(/^https?:\/\//, '').toLowerCase(),
fullName: siteInfo?.fullname,
fullname: siteInfo?.fullname,
firstname: siteInfo?.firstname,
lastname: siteInfo?.lastname,
siteName: siteInfo?.sitename,
avatar: siteInfo?.userpictureurl,
userpictureurl: siteInfo?.userpictureurl,
siteHomeId: siteInfo?.siteid || 1,
loggedOut: !!site.loggedOut,
};
@ -1300,8 +1302,8 @@ export class CoreSitesProvider {
}
// Finally use fullname.
textA = a.fullName?.toLowerCase().trim() || '';
textB = b.fullName?.toLowerCase().trim() || '';
textA = a.fullname?.toLowerCase().trim() || '';
textB = b.fullname?.toLowerCase().trim() || '';
return textA.localeCompare(textB);
});
@ -2016,9 +2018,11 @@ export type CoreSiteBasicInfo = {
id: string; // Site ID.
siteUrl: string; // Site URL.
siteUrlWithoutProtocol: string; // Site URL without protocol.
fullName?: string; // User's full name.
fullname?: string; // User's full name.
firstname?: string; // User's first name.
lastname?: string; // User's last name.
userpictureurl?: string; // User avatar.
siteName?: string; // Site's name.
avatar?: string; // User's avatar.
badge?: number; // Badge to display in the site.
siteHomeId?: number; // Site home ID.
loggedOut: boolean; // If Site is logged out.

View File

@ -330,6 +330,7 @@ html {
--core-large-avatar-size: 90px;
--core-avatar-size: var(--a11y-min-target-size);
--core-avatar-radius: 50%;
--core-courseimage-on-course-size: 72px;
--core-courseimage-radius: var(--medium-radius);

View File

@ -1,6 +1,10 @@
This files describes API changes in the Moodle Mobile app,
information provided here is intended especially for developers.
=== 4.3.0 ===
- CoreSiteBasicInfo fullName attribute has changed to fullname and avatar to userpictureurl to match user fields.
=== 4.2.0 ===
- CoreIconComponent has been removed after deprecation period: Use CoreFaIconDirective instead.