MOBILE-4061 core: Create a new message component to fix animations

main
Pau Ferrer Ocaña 2022-06-09 11:01:59 +02:00
parent ef574e7e63
commit e337bc64d5
19 changed files with 378 additions and 336 deletions

View File

@ -81,47 +81,16 @@
<ion-icon name="fas-arrow-down" aria-hidden="true"></ion-icon> <ion-icon name="fas-arrow-down" aria-hidden="true"></ion-icon>
</ion-chip> </ion-chip>
<ion-item class="ion-text-wrap addon-message" (longPress)="copyMessage(message)" <core-message [message]="message" [user]="members[message.useridfrom]" (afterRender)="last && scrollToBottom()"
[class.addon-message-mine]="message.useridfrom == currentUserId" [text]="message.text" (onDeleteMessage)="deleteMessage(message, index)" [showDelete]="showDelete"
[class.addon-message-not-mine]="message.useridfrom != currentUserId" [time]="message.timecreated">
[class.addon-message-no-user]="!message.showUserData" </core-message>
[@coreSlideInOut]="message.useridfrom == currentUserId ? '' : 'fromLeft'">
<ion-label>
<!-- User data. -->
<div *ngIf="message.showUserData" class="item-heading addon-message-user">
<core-user-avatar slot="start" [user]="members[message.useridfrom]" [linkProfile]="false" aria-hidden="true">
</core-user-avatar>
<div>{{ members[message.useridfrom].fullname }}</div>
</div>
<div *ngIf="!message.showUserData" class="sr-only">
{{ message.useridfrom == currentUserId
? ('addon.messages.you' | translate)
: members[message.useridfrom].fullname }}
</div>
<!-- Some messages have <p> and some others don't. Add a <p> so they all have same styles. -->
<div class="addon-message-text">
<core-format-text (afterRender)="last && scrollToBottom()" [text]="message.text" contextLevel="system"
[contextInstanceId]="0"></core-format-text>
</div>
</ion-label>
<ion-note *ngIf="!message.pending" slot="end">{{ message.timecreated | coreFormatDate: "strftimetime" }}</ion-note>
<ion-note *ngIf="message.pending" slot="end">
<ion-icon name="fas-clock" [attr.aria-label]="'core.notsent' | translate" role="status"></ion-icon>
</ion-note>
<ion-button fill="clear" *ngIf="!message.sending && showDelete" (click)="deleteMessage(message, index)"
class="addon-messages-delete-button" [@coreSlideInOut]="'fromRight'"
[attr.aria-label]=" 'addon.messages.deletemessage' | translate" slot="end">
<ion-icon name="fas-trash" color="danger" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
<div class="tail" *ngIf="message.showTail"></div>
</ion-item>
</ng-container> </ng-container>
</ion-list> </ion-list>
<core-empty-box *ngIf="!messages || messages.length <= 0" icon="far-comments" <core-empty-box *ngIf="!messages || messages.length <= 0" icon="far-comments"
[message]="'addon.messages.nomessagesfound' | translate"></core-empty-box> [message]="'addon.messages.nomessagesfound' | translate">
</core-empty-box>
</core-loading> </core-loading>
<!-- Scroll bottom. --> <!-- Scroll bottom. -->
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="loaded && newMessages > 0"> <ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="loaded && newMessages > 0">

View File

@ -37,7 +37,6 @@ import { CoreApp } from '@services/app';
import { CoreInfiniteLoadingComponent } from '@components/infinite-loading/infinite-loading'; import { CoreInfiniteLoadingComponent } from '@components/infinite-loading/infinite-loading';
import { Md5 } from 'ts-md5/dist/md5'; import { Md5 } from 'ts-md5/dist/md5';
import moment from 'moment'; import moment from 'moment';
import { CoreAnimations } from '@components/animations';
import { CoreError } from '@classes/errors/error'; import { CoreError } from '@classes/errors/error';
import { Translate } from '@singletons'; import { Translate } from '@singletons';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
@ -53,7 +52,6 @@ import { CoreDom } from '@singletons/dom';
@Component({ @Component({
selector: 'page-addon-messages-discussion', selector: 'page-addon-messages-discussion',
templateUrl: 'discussion.html', templateUrl: 'discussion.html',
animations: [CoreAnimations.SLIDE_IN_OUT],
styleUrls: ['discussion.scss'], styleUrls: ['discussion.scss'],
}) })
export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterViewInit { export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterViewInit {
@ -305,7 +303,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
} else { } else {
if (this.userId) { if (this.userId) {
// Fake the user member info. // Fake the user member info.
promises.push(CoreUser.getProfile(this.userId!).then(async (user) => { promises.push(CoreUser.getProfile(this.userId).then(async (user) => {
this.otherMember = { this.otherMember = {
id: user.id, id: user.id,
fullname: user.fullname, fullname: user.fullname,
@ -524,7 +522,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
return; return;
} }
const messages = Array.from(this.hostElement.querySelectorAll('.addon-message-not-mine')) const messages = Array.from(this.hostElement.querySelectorAll('core-message:not(.is-mine)'))
.slice(-this.newMessages) .slice(-this.newMessages)
.reverse(); .reverse();
@ -555,7 +553,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
// Try to get the conversationId if we don't have it. // Try to get the conversationId if we don't have it.
if (!conversationId && userId) { if (!conversationId && userId) {
try { try {
if (userId == this.currentUserId && AddonMessages.isSelfConversationEnabled()) { if (userId === this.currentUserId && AddonMessages.isSelfConversationEnabled()) {
fallbackConversation = await AddonMessages.getSelfConversation(); fallbackConversation = await AddonMessages.getSelfConversation();
} else { } else {
fallbackConversation = await AddonMessages.getConversationBetweenUsers(userId, undefined, true); fallbackConversation = await AddonMessages.getConversationBetweenUsers(userId, undefined, true);
@ -563,7 +561,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
conversationId = fallbackConversation.id; conversationId = fallbackConversation.id;
} catch (error) { } catch (error) {
// Probably conversation does not exist or user is offline. Try to load offline messages. // Probably conversation does not exist or user is offline. Try to load offline messages.
this.isSelf = userId == this.currentUserId; this.isSelf = userId === this.currentUserId;
const messages = await AddonMessagesOffline.getMessages(userId); const messages = await AddonMessagesOffline.getMessages(userId);
@ -584,11 +582,15 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
} }
} }
if (!conversationId) {
return false;
}
// Retrieve the conversation. Invalidate data first to get the right unreadcount. // Retrieve the conversation. Invalidate data first to get the right unreadcount.
await AddonMessages.invalidateConversation(conversationId!); await AddonMessages.invalidateConversation(conversationId);
try { try {
this.conversation = await AddonMessages.getConversation(conversationId!, undefined, true); this.conversation = await AddonMessages.getConversation(conversationId, undefined, true);
} catch (error) { } catch (error) {
// Get conversation failed, use the fallback one if we have it. // Get conversation failed, use the fallback one if we have it.
if (fallbackConversation) { if (fallbackConversation) {
@ -947,7 +949,6 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
message: AddonMessagesConversationMessageFormatted, message: AddonMessagesConversationMessageFormatted,
index: number, index: number,
): Promise<void> { ): Promise<void> {
const canDeleteAll = this.conversation && this.conversation.candeletemessagesforallusers; const canDeleteAll = this.conversation && this.conversation.candeletemessagesforallusers;
const langKey = message.pending || canDeleteAll || this.isSelf ? 'core.areyousure' : const langKey = message.pending || canDeleteAll || this.isSelf ? 'core.areyousure' :
'addon.messages.deletemessageconfirmation'; 'addon.messages.deletemessageconfirmation';
@ -1099,7 +1100,7 @@ export class AddonMessagesDiscussionPage implements OnInit, OnDestroy, AfterView
*/ */
scrollToFirstUnreadMessage(): void { scrollToFirstUnreadMessage(): void {
if (this.newMessages > 0) { if (this.newMessages > 0) {
const messages = Array.from(this.hostElement.querySelectorAll<HTMLElement>('.addon-message-not-mine')); const messages = Array.from(this.hostElement.querySelectorAll<HTMLElement>('core-message:not(.is-mine)'));
CoreDom.scrollToElement(messages[messages.length - this.newMessages]); CoreDom.scrollToElement(messages[messages.length - this.newMessages]);
} }

View File

@ -106,7 +106,6 @@ Feature: Test basic usage of messages in app
And I should find "hi" in the app And I should find "hi" in the app
And I should find "byee" in the app And I should find "byee" in the app
# TODO Fix this test in all Moodle versions
Scenario: User profile: send message, add/remove contact Scenario: User profile: send message, add/remove contact
Given I entered the app as "teacher1" Given I entered the app as "teacher1"
When I press "Messages" in the app When I press "Messages" in the app

View File

@ -81,27 +81,10 @@
</ion-badge> </ion-badge>
</div> </div>
<ion-item *ngIf="!message.special" class="ion-text-wrap addon-message" <core-message *ngIf="!message.special" [message]="message" [user]="message" [text]="message.message"
[class.addon-message-mine]="message.userid == currentUserId" [time]="message.timestamp * 1000" (afterRender)="last && scrollToBottom()" contextLevel="module" [instanceId]="cmId"
[class.addon-message-not-mine]="message.userid != currentUserId" [class.addon-message-no-user]="!message.showUserData" [courseId]="courseId">
[@coreSlideInOut]="message.userid == currentUserId ? '' : 'fromLeft'"> </core-message>
<ion-label>
<!-- User data. -->
<h2 class="addon-message-user" *ngIf="message.showUserData">
<core-user-avatar slot="start" [user]="message" [linkProfile]="false">
</core-user-avatar>
<div>{{ message.userfullname }}</div>
</h2>
<div class="addon-message-text">
<core-format-text [text]="message.message" contextLevel="module" [contextInstanceId]="cmId"
[courseId]="courseId" (afterRender)="last && scrollToBottom()">
</core-format-text>
</div>
</ion-label>
<ion-note slot="end">{{ message.timestamp * 1000 | coreFormatDate: "strftimetime" }}</ion-note>
<div class="tail" *ngIf="message.showTail"></div>
</ion-item>
</ng-container> </ng-container>
</ion-list> </ion-list>

View File

@ -13,7 +13,6 @@
// limitations under the License. // limitations under the License.
import { Component, ViewChild, OnInit, OnDestroy } from '@angular/core'; import { Component, ViewChild, OnInit, OnDestroy } from '@angular/core';
import { CoreAnimations } from '@components/animations';
import { CoreSendMessageFormComponent } from '@components/send-message-form/send-message-form'; import { CoreSendMessageFormComponent } from '@components/send-message-form/send-message-form';
import { CanLeave } from '@guards/can-leave'; import { CanLeave } from '@guards/can-leave';
import { IonContent } from '@ionic/angular'; import { IonContent } from '@ionic/angular';
@ -36,7 +35,6 @@ import { AddonModChatFormattedMessage, AddonModChatHelper } from '../../services
@Component({ @Component({
selector: 'page-addon-mod-chat-chat', selector: 'page-addon-mod-chat-chat',
templateUrl: 'chat.html', templateUrl: 'chat.html',
animations: [CoreAnimations.SLIDE_IN_OUT],
styleUrls: ['chat.scss'], styleUrls: ['chat.scss'],
}) })
export class AddonModChatChatPage implements OnInit, OnDestroy, CanLeave { export class AddonModChatChatPage implements OnInit, OnDestroy, CanLeave {

View File

@ -75,26 +75,9 @@
</ion-badge> </ion-badge>
</div> </div>
<ion-item *ngIf="!message.special" class="ion-text-wrap addon-message" <core-message *ngIf="!message.special" [message]="message" [user]="message" [text]="message.message"
[class.addon-message-mine]="message.userid == currentUserId" [time]="message.timestamp * 1000" contextLevel="module" [instanceId]="cmId" [courseId]="courseId">
[class.addon-message-not-mine]="message.userid != currentUserId" [class.addon-message-no-user]="!message.showUserData"> </core-message>
<ion-label>
<!-- User data. -->
<h2 class="addon-message-user">
<core-user-avatar slot="start" [user]="message" [linkProfile]="false" *ngIf="message.showUserData">
</core-user-avatar>
<div *ngIf="message.showUserData">{{ message.userfullname }}</div>
</h2>
<div class="addon-message-text">
<core-format-text [text]="message.message" contextLevel="module" [contextInstanceId]="cmId"
[courseId]="courseId">
</core-format-text>
</div>
</ion-label>
<ion-note slot="end">{{ message.timestamp * 1000 | coreFormatDate: "strftimetime" }}</ion-note>
<div class="tail" *ngIf="message.showTail"></div>
</ion-item>
</ng-container> </ng-container>
</ion-list> </ion-list>
</core-loading> </core-loading>

View File

@ -36,7 +36,7 @@ export class CoreAnimations {
animate(300, keyframes([ animate(300, keyframes([
style({ opacity: 0, transform: 'translateX(-100%)', offset: 0 }), style({ opacity: 0, transform: 'translateX(-100%)', offset: 0 }),
style({ opacity: 1, transform: 'translateX(5%)', offset: 0.7 }), style({ opacity: 1, transform: 'translateX(5%)', offset: 0.7 }),
style({ opacity: 1, transform: 'translateX(0)', offset: 1.0 }), style({ opacity: 1, transform: 'translateX(0)', offset: 1 }),
])), ])),
]), ]),
// Leave animation. // Leave animation.
@ -44,7 +44,7 @@ export class CoreAnimations {
animate(300, keyframes([ animate(300, keyframes([
style({ opacity: 1, transform: 'translateX(0)', offset: 0 }), style({ opacity: 1, transform: 'translateX(0)', offset: 0 }),
style({ opacity: 1, transform: 'translateX(5%)', offset: 0.3 }), style({ opacity: 1, transform: 'translateX(5%)', offset: 0.3 }),
style({ opacity: 0, transform: 'translateX(-100%)', offset: 1.0 }), style({ opacity: 0, transform: 'translateX(-100%)', offset: 1 }),
])), ])),
]), ]),
// Enter animation. // Enter animation.
@ -52,7 +52,7 @@ export class CoreAnimations {
animate(300, keyframes([ animate(300, keyframes([
style({ opacity: 0, transform: 'translateX(100%)', offset: 0 }), style({ opacity: 0, transform: 'translateX(100%)', offset: 0 }),
style({ opacity: 1, transform: 'translateX(-5%)', offset: 0.7 }), style({ opacity: 1, transform: 'translateX(-5%)', offset: 0.7 }),
style({ opacity: 1, transform: 'translateX(0)', offset: 1.0 }), style({ opacity: 1, transform: 'translateX(0)', offset: 1 }),
])), ])),
]), ]),
// Leave animation. // Leave animation.
@ -60,7 +60,7 @@ export class CoreAnimations {
animate(300, keyframes([ animate(300, keyframes([
style({ opacity: 1, transform: 'translateX(0)', offset: 0 }), style({ opacity: 1, transform: 'translateX(0)', offset: 0 }),
style({ opacity: 1, transform: 'translateX(-5%)', offset: 0.3 }), style({ opacity: 1, transform: 'translateX(-5%)', offset: 0.3 }),
style({ opacity: 0, transform: 'translateX(100%)', offset: 1.0 }), style({ opacity: 0, transform: 'translateX(100%)', offset: 1 }),
])), ])),
]), ]),
]); ]);

View File

@ -61,6 +61,7 @@ import { CoreHorizontalScrollControlsComponent } from './horizontal-scroll-contr
import { CoreButtonWithSpinnerComponent } from './button-with-spinner/button-with-spinner'; import { CoreButtonWithSpinnerComponent } from './button-with-spinner/button-with-spinner';
import { CoreSwipeSlidesComponent } from './swipe-slides/swipe-slides'; import { CoreSwipeSlidesComponent } from './swipe-slides/swipe-slides';
import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-navigation-tour'; import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-navigation-tour';
import { CoreMessageComponent } from './message/message';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -84,6 +85,7 @@ import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-
CoreLoadingComponent, CoreLoadingComponent,
CoreLocalFileComponent, CoreLocalFileComponent,
CoreMarkRequiredComponent, CoreMarkRequiredComponent,
CoreMessageComponent,
CoreModIconComponent, CoreModIconComponent,
CoreNavBarButtonsComponent, CoreNavBarButtonsComponent,
CoreNavigationBarComponent, CoreNavigationBarComponent,
@ -134,6 +136,7 @@ import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-
CoreLoadingComponent, CoreLoadingComponent,
CoreLocalFileComponent, CoreLocalFileComponent,
CoreMarkRequiredComponent, CoreMarkRequiredComponent,
CoreMessageComponent,
CoreModIconComponent, CoreModIconComponent,
CoreNavBarButtonsComponent, CoreNavBarButtonsComponent,
CoreNavigationBarComponent, CoreNavigationBarComponent,

View File

@ -0,0 +1,45 @@
<div *ngIf="message" class="message-box" (longPress)="copyMessage()">
<div class="main">
<!-- User data. -->
<div class="message-user" *ngIf="message.showUserData">
<core-user-avatar slot="start" [user]="user" [courseId]="courseId" [linkProfile]="false" aria-hidden="true">
</core-user-avatar>
<div>{{ userFullname }}</div>
</div>
<div *ngIf="!message.showUserData" class="sr-only">
{{ isMine
? ('addon.messages.you' | translate)
: userFullname }}
</div>
<core-format-text class="message-text" [text]="text" (afterRender)="afterRender.emit()" [contextLevel]="contextLevel"
[contextInstanceId]="instanceId" [courseId]="courseId">
</core-format-text>
</div>
<div class="extra">
<div class="message-time">
<ng-container *ngIf="!message.pending && !message.deleted">
{{ time | coreFormatDate: 'strftimetime' }}
</ng-container>
<ng-container *ngIf="!message.pending && message.deleted">
<ion-icon name="fas-trash" aria-hidden="true"></ion-icon>
<span class="ion-text-wrap">
{{ 'core.deletedoffline' | translate }}
</span>
</ng-container>
<ion-icon *ngIf="message.pending" name="fas-clock" [attr.aria-label]="'core.notsent' | translate" role="status"></ion-icon>
</div>
<ion-button *ngIf="showDelete && !message.deleted && message.delete !== false" fill="clear" [@coreSlideInOut]="'fromRight'"
color="danger" (click)="delete($event)" [attr.aria-label]="'addon.messages.deletemessage' | translate" class="delete-button">
<ion-icon name="fas-trash" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
<ion-button *ngIf="showDelete && message.deleted" fill="clear" color="danger" (click)="undoDelete($event)"
[attr.aria-label]="'core.restore' | translate" class="delete-button">
<ion-icon name="fas-undo-alt" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</div>
<div class="tail" *ngIf="message.showTail"></div>
</div>

View File

@ -0,0 +1,138 @@
@import "~theme/globals";
:host {
--message-background: var(--core-messages-message-bg);
--message-activated-background: var(--core-messages-message-activated-bg);
--message-alignment: flex-start;
display: flex;
justify-content: var(--message-alignment);
.message-box {
--background: var(--message-background);
--min-height: var(--a11y-min-target-size);
display: flex;
flex-direction: row;
position: relative;
border: 0;
border-radius: var(--medium-radius);
margin: 8px;
width: 90%;
max-width: var(--list-item-max-width);
min-height: 36px;
font-size: var(--text-size);
color: var(--ion-text-color);
background: var(--message-background);
@include core-transition(width);
// This is needed to display bubble tails.
overflow: visible;
&:hover {
-webkit-filter: drop-shadow(2px 2px 2px rgba(0,0,0,.3));
filter: drop-shadow(2px 2px 2px rgba(0,0,0,.3));
}
&[tappable]:active {
--message-background: var(--message-activated-background);
}
.main {
padding: 8px;
flex-grow: 1;
.message-user {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: .5rem;
margin-top: 0;
color: var(--ion-text-color);
core-user-avatar {
display: block;
--core-avatar-size: var(--core-messages-avatar-size);
margin: 0;
}
div {
font-weight: 500;
flex-grow: 1;
padding-left: .5rem;
padding-right: .5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 16px;
}
}
.message-text {
::ng-deep > p:only-child {
display: inline;
margin: 0;
}
}
}
.extra {
flex-shrink: 1;
display: flex;
flex-direction: row;
padding-left: 8px;
padding-right: 8px;
.message-time {
padding-top: 8px;
color: var(--core-messages-message-note-text);
font-size: var(--core-messages-message-note-font-size);
}
.delete-button {
min-height: initial;
line-height: initial;
margin: 0px;
align-self: flex-end;
::ng-deep ion-icon {
font-size: 1.2em;
}
}
}
.tail {
content: '';
width: 0;
height: 0;
border: 0.5rem solid transparent;
position: absolute;
touch-action: none;
bottom: 0;
border-bottom-color: var(--message-background);
@include position(null, null, null, -8px);
}
}
&.no-user .message-box {
margin-top: 0px;
}
&.is-mine {
// Defined when a message is the user's.
--message-background: var(--core-messages-message-mine-bg);
--message-activated-background: var(--core-messages-message-mine-activated-bg);
--message-alignment: flex-end;
.message-box {
.tail {
@include position(null, -8px, null, unset);
}
}
}
}

View File

@ -0,0 +1,121 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { ContextLevel } from '@/core/constants';
import { Component, EventEmitter, HostBinding, Input, OnInit, Output } from '@angular/core';
import { CoreAnimations } from '@components/animations';
import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUserWithAvatar } from '@components/user-avatar/user-avatar';
/**
* Component to handle a message in a conversation.
*/
@Component({
selector: 'core-message',
templateUrl: 'message.html',
styleUrls: ['message.scss'],
animations: [CoreAnimations.SLIDE_IN_OUT],
})
export class CoreMessageComponent implements OnInit {
@Input() message?: CoreMessageData; // The message object.
@Input() user?: CoreUserWithAvatar; // The user object.
@Input() text = ''; // Message text.
@Input() time = 0; // Message time.
@Input() instanceId = 0;
@Input() courseId?: number;
@Input() contextLevel: ContextLevel = ContextLevel.SYSTEM;
@Input() showDelete = false;
@Output() onDeleteMessage = new EventEmitter<void>();
@Output() onUndoDeleteMessage = new EventEmitter<void>();
@Output() afterRender = new EventEmitter<void>();
protected deleted = false; // Needed to fix animation to void in Behat tests.
// @TODO Recover the animation using native css or wait for Angular 13.1
// where the bug https://github.com/angular/angular/issues/30693 is solved.
// @HostBinding('@coreSlideInOut') get animation(): string {
// return this.isMine ? '' : 'fromLeft';
// }
@HostBinding('class.is-mine') isMine = false;
@HostBinding('class.no-user') get showUser(): boolean {
return !this.message?.showUserData;
};
get userId(): number | undefined {
return this.user && (this.user.userid || this.user.id);
}
get userFullname(): string | undefined {
return this.user && (this.user.fullname || this.user.userfullname);
}
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
const currentUserId = CoreSites.getCurrentSiteUserId();
this.isMine = this.userId === currentUserId;
}
/**
* Emits the delete action.
*
* @param event Event.
*/
delete(event: Event): void {
event.preventDefault();
event.stopPropagation();
this.onDeleteMessage.emit();
}
/**
* Emits the undo delete action.
*
* @param event Event.
*/
undoDelete(event: Event): void {
event.preventDefault();
event.stopPropagation();
this.onUndoDeleteMessage.emit();
}
/**
* Copy message to clipboard.
*/
copyMessage(): void {
CoreUtils.copyToClipboard(CoreTextUtils.decodeHTMLEntities(this.text));
}
}
/**
* Conversation message with some calculated data.
*/
type CoreMessageData = {
pending?: boolean; // Whether the message is pending to be sent.
sending?: boolean; // Whether the message is being sent right now.
showDate?: boolean; // Whether to show the date before the message.
deleted?: boolean; // Whether the message has been deleted.
showUserData?: boolean; // Whether to show the user data in the message.
showTail?: boolean; // Whether to show a "tail" in the message.
delete?: boolean; // Permission to delete=true/false.
};

View File

@ -156,7 +156,7 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy {
/** /**
* Type with all possible formats of user. * Type with all possible formats of user.
*/ */
type CoreUserWithAvatar = CoreUserBasicData & { export type CoreUserWithAvatar = CoreUserBasicData & {
userpictureurl?: string; userpictureurl?: string;
userprofileimageurl?: string; userprofileimageurl?: string;
profileimageurlsmall?: string; profileimageurlsmall?: string;

View File

@ -45,71 +45,20 @@
{{ comment.timecreated * 1000 | coreFormatDate: "strftimedayshort" }} {{ comment.timecreated * 1000 | coreFormatDate: "strftimedayshort" }}
</p> </p>
<ion-item class="ion-text-wrap addon-message" [class.addon-message-mine]="comment.userid == currentUserId" <core-message [message]="comment" [text]="comment.content" [time]="comment.timecreated * 1000" [user]="comment"
[class.addon-message-not-mine]="comment.userid != currentUserId" [class.addon-message-no-user]="!comment.showUserData" [showDelete]="showDelete" [contextLevel]="contextLevel" [instanceId]="instanceId" [courseId]="courseId"
[@coreSlideInOut]="comment.userid == currentUserId ? '' : 'fromLeft'"> (onDeleteMessage)="deleteComment(comment)" (onUndoDeleteMessage)="undoDeleteComment(comment)">
<ion-label> </core-message>
<!-- User data. -->
<h2 class="addon-message-user" *ngIf="comment.showUserData">
<core-user-avatar slot="start" [user]="comment" [linkProfile]="false">
</core-user-avatar>
<div>{{ comment.fullname }}</div>
</h2>
<div class="addon-message-text">
<core-format-text [text]="comment.content" [contextLevel]="contextLevel" [contextInstanceId]="instanceId"
[courseId]="courseId">
</core-format-text>
</div>
</ion-label>
<ion-note slot="end">
<ng-container *ngIf="!comment.deleted">
{{ comment.timecreated * 1000 | coreFormatDate: 'strftimetime' }}
</ng-container>
<ng-container *ngIf="comment.deleted">
<ion-icon name="fas-trash" aria-hidden="true"></ion-icon> <span class="ion-text-wrap">
{{ 'core.deletedoffline' | translate }}
</span>
</ng-container>
</ion-note>
<div class="tail" *ngIf="comment.showTail"></div>
<ion-button *ngIf="showDelete && !comment.deleted && comment.delete" slot="end" fill="clear"
[@coreSlideInOut]="'fromRight'" color="danger" (click)="deleteComment($event, comment)"
[attr.aria-label]="'core.delete' | translate" class="addon-messages-delete-button">
<ion-icon name="fas-trash" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
<ion-button *ngIf="showDelete && comment.deleted" slot="end" fill="clear" color="danger"
(click)="undoDeleteComment($event, comment)" [attr.aria-label]="'core.restore' | translate"
class="addon-messages-delete-button">
<ion-icon name="fas-undo-alt" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-item>
</ng-container> </ng-container>
<ion-item *ngIf="hasOffline" class="ion-text-wrap addon-message addon-message-mine"> <ion-badge class="ion-text-wrap" color="info" *ngIf="hasOffline">
<ion-label> <ion-icon name="fas-exclamation-triangle" aria-hidden="true"></ion-icon>
<!-- User data. --> {{ 'core.thereisdatatosync' | translate:{$a: 'core.comments.comments' | translate | lowercase } }}
<p class="ion-text-center"> </ion-badge>
<ion-icon name="fas-exclamation-triangle" aria-hidden="true"></ion-icon> <core-message *ngIf="hasOffline && offlineComment" [message]="offlineComment" [text]="offlineComment.content"
{{ 'core.thereisdatatosync' | translate:{$a: 'core.comments.comments' | translate | lowercase } }} [user]="offlineComment" [showDelete]="showDelete" [contextLevel]="contextLevel" [instanceId]="instanceId"
</p> [courseId]="courseId" (onDeleteMessage)="deleteComment(offlineComment)">
</core-message>
<div class="addon-message-text" *ngIf="offlineComment">
<core-format-text [text]="offlineComment.content" [contextLevel]="contextLevel" [contextInstanceId]="instanceId"
[courseId]="courseId">
</core-format-text>
</div>
</ion-label>
<ion-note>
<ion-icon name="fas-clock" aria-hidden="true"></ion-icon> {{ 'core.notsent' | translate }}
</ion-note>
<div class="tail"></div>
<ion-button *ngIf="showDelete && offlineComment" slot="end" fill="clear" [@coreSlideInOut]="'fromRight'" color="danger"
(click)="deleteComment($event, offlineComment)" [attr.aria-label]="'core.delete' | translate"
class="addon-messages-delete-button">
<ion-icon name="fas-trash" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-item>
</ion-list> </ion-list>
</core-loading> </core-loading>

View File

@ -14,7 +14,6 @@
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreAnimations } from '@components/animations';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { import {
@ -43,6 +42,7 @@ import { CoreApp } from '@services/app';
import { CoreNetwork } from '@services/network'; import { CoreNetwork } from '@services/network';
import moment from 'moment'; import moment from 'moment';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { CoreAnimations } from '@components/animations';
/** /**
* Page that displays comments. * Page that displays comments.
@ -75,7 +75,7 @@ export class CoreCommentsViewerPage implements OnInit, OnDestroy {
hasOffline = false; hasOffline = false;
refreshIcon = CoreConstants.ICON_LOADING; refreshIcon = CoreConstants.ICON_LOADING;
syncIcon = CoreConstants.ICON_LOADING; syncIcon = CoreConstants.ICON_LOADING;
offlineComment?: CoreCommentsOfflineWithUser; offlineComment?: CoreCommentsOfflineWithUser & { pending?: boolean };
currentUserId: number; currentUserId: number;
sending = false; sending = false;
newComment = ''; newComment = '';
@ -361,13 +361,9 @@ export class CoreCommentsViewerPage implements OnInit, OnDestroy {
/** /**
* Delete a comment. * Delete a comment.
* *
* @param e Click event.
* @param comment Comment to delete. * @param comment Comment to delete.
*/ */
async deleteComment(e: Event, comment: CoreCommentsDataToDisplay | CoreCommentsOfflineWithUser): Promise<void> { async deleteComment(comment: CoreCommentsDataToDisplay | CoreCommentsOfflineWithUser): Promise<void> {
e.preventDefault();
e.stopPropagation();
const modified = 'lastmodified' in comment const modified = 'lastmodified' in comment
? comment.lastmodified ? comment.lastmodified
: comment.timecreated; : comment.timecreated;
@ -529,15 +525,16 @@ export class CoreCommentsViewerPage implements OnInit, OnDestroy {
).then(async (offlineComment) => { ).then(async (offlineComment) => {
this.offlineComment = offlineComment; this.offlineComment = offlineComment;
if (!offlineComment) { if (!this.offlineComment) {
return; return;
} }
if (this.newComment == '') { if (this.newComment == '') {
this.newComment = this.offlineComment!.content; this.newComment = this.offlineComment.content;
} }
this.offlineComment!.userid = this.currentUserId; this.offlineComment.userid = this.currentUserId;
this.offlineComment.pending = true;
return; return;
})); }));
@ -573,13 +570,9 @@ export class CoreCommentsViewerPage implements OnInit, OnDestroy {
/** /**
* Restore a comment. * Restore a comment.
* *
* @param e Click event.
* @param comment Comment to delete. * @param comment Comment to delete.
*/ */
async undoDeleteComment(e: Event, comment: CoreCommentsDataToDisplay): Promise<void> { async undoDeleteComment(comment: CoreCommentsDataToDisplay): Promise<void> {
e.preventDefault();
e.stopPropagation();
await CoreCommentsOffline.undoDeleteComment(comment.id); await CoreCommentsOffline.undoDeleteComment(comment.id);
comment.deleted = false; comment.deleted = false;

View File

@ -1 +1,5 @@
@import "~theme/components/discussion.scss"; @import "~theme/components/discussion.scss";
ion-badge {
margin: 8px auto;
}

View File

@ -27,148 +27,3 @@ ion-content {
font-weight: normal; font-weight: normal;
font-size: 0.9rem; font-size: 0.9rem;
} }
// Message item.
ion-item.addon-message {
--message-background: var(--addon-messages-message-bg);
--message-activated-background: var(--addon-messages-message-activated-bg);
--message-alignment: flex-start;
border: 0;
border-radius: var(--medium-radius);
padding: 0 8px 0 8px;
margin: 8px;
--background: var(--message-background);
background: var(--message-background);
align-self: var(--message-alignment);
width: 90%;
max-width: var(--list-item-max-width);
--min-height: var(--a11y-min-target-size);
position: relative;
@include core-transition(width);
// This is needed to display bubble tails.
overflow: visible;
&::part(native) {
--inner-border-width: 0px;
--inner-padding-end: 0px;
padding: 0;
margin: 0;
}
&:hover {
-webkit-filter: drop-shadow(2px 2px 2px rgba(0,0,0,.3));
filter: drop-shadow(2px 2px 2px rgba(0,0,0,.3));
}
core-format-text > p:only-child {
display: inline;
}
.addon-message-user {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: .5rem;
margin-top: 0;
color: var(--ion-text-color);
core-user-avatar {
display: block;
--core-avatar-size: var(--addon-messages-avatar-size);
margin: 0;
}
div {
font-weight: 500;
flex-grow: 1;
padding-left: .5rem;
padding-right: .5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
ion-note {
color: var(--addon-messages-message-note-text);
font-size: var(--addon-messages-message-note-font-size);
margin: 0;
padding: 8px 0;
align-self: flex-start;
}
&[tappable]:active {
--message-background: var(--message-activated-background);
}
ion-label {
margin: 0;
padding: 8px 0;
}
.addon-message-text {
display: inline-flex;
* {
color: var(--ion-text-color);
}
}
.tail {
content: '';
width: 0;
height: 0;
border: 0.5rem solid transparent;
position: absolute;
touch-action: none;
bottom: 0;
border-bottom-color: var(--message-background);
}
// Defines when an item-message is the user's.
&.addon-message-mine {
--message-background: var(--addon-messages-message-mine-bg);
--message-activated-background: var(--addon-messages-message-mine-activated-bg);
--message-alignment: flex-end;
.spinner {
@include float(end);
@include margin(2px, -3px, -2px, 5px);
svg {
width: 16px;
height: 16px;
}
}
.tail {
@include position(null, -8px, null, null);
@include margin-horizontal(null, -0.5rem);
}
}
&.addon-message-not-mine .tail {
@include position(null, null, null, -8px);
@include margin-horizontal(-0.5rem, null);
}
.addon-messages-delete-button {
min-height: initial;
line-height: initial;
margin-top: 0px;
margin-bottom: 0px;
height: var(--a11y-min-target-size) !important;
align-self: flex-end;
ion-icon {
font-size: 1.4em;
line-height: initial;
color: var(--danger);
}
}
&.addon-message-no-user {
margin-top: 0px;
}
}

View File

@ -146,13 +146,13 @@
--core-collapsible-footer-background: var(--contrast-background); --core-collapsible-footer-background: var(--contrast-background);
--addon-messages-message-bg: var(--gray-800); --core-messages-message-bg: var(--gray-800);
--addon-messages-message-activated-bg: var(--gray-700); --core-messages-message-activated-bg: var(--gray-700);
--addon-messages-message-note-text: var(--subdued-text-color); --core-messages-message-note-text: var(--subdued-text-color);
--addon-messages-message-mine-bg: var(--gray-700); --core-messages-message-mine-bg: var(--gray-700);
--addon-messages-message-mine-activated-bg: var(--gray-600); --core-messages-message-mine-activated-bg: var(--gray-600);
--addon-messages-discussion-badge: var(--primary); --core-messages-discussion-badge: var(--primary);
--addon-messages-discussion-badge-text: var(--gray-100); --core-messages-discussion-badge-text: var(--gray-100);
--addon-forum-border-color: var(--gray-500); --addon-forum-border-color: var(--gray-500);
--addon-forum-highlight-color: var(--gray-200); --addon-forum-highlight-color: var(--gray-200);

View File

@ -344,15 +344,15 @@
--addon-calendar-today-border-color: var(--primary); --addon-calendar-today-border-color: var(--primary);
--addon-calendar-border-color: var(--stroke); --addon-calendar-border-color: var(--stroke);
--addon-messages-message-bg: var(--white); --core-messages-message-bg: var(--white);
--addon-messages-message-activated-bg: var(--gray-200); --core-messages-message-activated-bg: var(--gray-200);
--addon-messages-message-note-text: var(--gray-500); --core-messages-message-note-text: var(--gray-500);
--addon-messages-message-note-font-size: 75%; --core-messages-message-note-font-size: 75%;
--addon-messages-message-mine-bg: var(--gray-300); --core-messages-message-mine-bg: var(--gray-300);
--addon-messages-message-mine-activated-bg: var(--gray-400); --core-messages-message-mine-activated-bg: var(--gray-400);
--addon-messages-avatar-size: 30px; --core-messages-avatar-size: 30px;
--addon-messages-discussion-badge: var(--primary); --core-messages-discussion-badge: var(--primary);
--addon-messages-discussion-badge-text: var(--white); --core-messages-discussion-badge-text: var(--white);
--addon-forum-avatar-size: var(--core-avatar-size); --addon-forum-avatar-size: var(--core-avatar-size);
--addon-forum-border-color: var(--stroke); --addon-forum-border-color: var(--stroke);

View File

@ -1,9 +1,10 @@
This files describes API changes in the Moodle Mobile app, This files describes API changes in the Moodle Mobile app,
information provided here is intended especially for developers. information provided here is intended especially for developers.
=== 4.0.1 === === 4.1.0 ===
- Zoom levels changed from "normal / low / high" to " none / medium / high". - Zoom levels changed from "normal / low / high" to " none / medium / high".
- --addon-messages-* CSS3 variables have been renamed to --core-messages-*
=== 4.0.0 === === 4.0.0 ===