MOBILE-2632 message: View group info

main
Dani Palou 2018-11-22 11:07:53 +01:00
parent 6acbdff97b
commit ea78f65656
12 changed files with 417 additions and 43 deletions

View File

@ -178,6 +178,7 @@
"addon.messages.nogroupmessages": "message",
"addon.messages.nomessages": "message",
"addon.messages.nousersfound": "local_moodlemobileapp",
"addon.messages.numparticipants": "message",
"addon.messages.removecontact": "message",
"addon.messages.removecontactconfirm": "local_moodlemobileapp",
"addon.messages.showdeletemessages": "local_moodlemobileapp",
@ -188,6 +189,7 @@
"addon.messages.type_strangers": "local_moodlemobileapp",
"addon.messages.unblockuser": "message",
"addon.messages.unblockuserconfirm": "message",
"addon.messages.warningconversationmessagenotsent": "local_moodlemobileapp",
"addon.messages.warningmessagenotsent": "local_moodlemobileapp",
"addon.messages.you": "message",
"addon.mod_assign.acceptsubmissionstatement": "local_moodlemobileapp",

View File

@ -30,6 +30,7 @@
"nogroupmessages": "No group messages",
"nomessages": "No messages",
"nousersfound": "No users found",
"numparticipants": "{{$a}} participants",
"removecontact": "Remove contact",
"removecontactconfirm": "Contact will be removed from your contacts list.",
"showdeletemessages": "Show delete messages",

View File

@ -0,0 +1,42 @@
<ion-header>
<ion-navbar core-back-button>
<ion-title>{{ 'addon.messages.groupinfo' | translate }}</ion-title>
<ion-buttons end>
<button ion-button icon-only (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="close"></ion-icon>
</button>
</ion-buttons>
</ion-navbar>
</ion-header>
<ion-content>
<ion-refresher [enabled]="loaded" (ionRefresh)="refreshData($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<ion-item text-center *ngIf="conversation">
<div class="item-avatar-center" *ngIf="conversation.imageurl">
<img class="avatar" [src]="conversation.imageurl" core-external-content [alt]="conversation.name" role="presentation">
</div>
<h2><core-format-text [text]="conversation.name"></core-format-text></h2>
<p><core-format-text *ngIf="conversation.subname" [text]="conversation.subname"></core-format-text></p>
<p>{{ 'addon.messages.numparticipants' | translate:{$a: conversation.membercount} }}</p>
</ion-item>
<a ion-item text-wrap *ngFor="let member of members" (click)="closeModal(member.id)">
<ion-avatar item-start>
<img [src]="member.profileimageurl" [alt]="member.fullname" core-external-content onError="this.src='assets/img/user-avatar.png'">
<!-- @todo: Display connection status.
<span *ngIf="member.showonlinestatus" class="core-primary-circle" [ngClass]='{"addon-message-contact-online": member.isonline}'></span> -->
</ion-avatar>
<h2>
<p>
<core-format-text [text]="member.fullname"></core-format-text>
<core-icon name="fa-ban" *ngIf="member.isblocked" [attr.aria-label]="'addon.messages.contactblocked' | translate"></core-icon>
</p>
</h2>
</a>
<core-infinite-loading [enabled]="canLoadMore" (action)="loadMoreMembers($event)"></core-infinite-loading>
</core-loading>
</ion-content>

View File

@ -0,0 +1,33 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { NgModule } from '@angular/core';
import { IonicPageModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { AddonMessagesConversationInfoPage } from './conversation-info';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
@NgModule({
declarations: [
AddonMessagesConversationInfoPage,
],
imports: [
CoreComponentsModule,
CoreDirectivesModule,
IonicPageModule.forChild(AddonMessagesConversationInfoPage),
TranslateModule.forChild()
],
})
export class AddonMessagesConversationInfoPageModule {}

View File

@ -0,0 +1,17 @@
ion-app.app-root page-addon-messages-group-conversations {
h2 {
display: flex;
justify-content: space-between;
.note {
margin: 0;
align-self: flex-end;
display: inline-flex;
font-size: initial;
}
}
core-format-text.addon-message-last-message {
display: inline;
}
}

View File

@ -0,0 +1,130 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { Component, OnInit } from '@angular/core';
import { IonicPage, NavParams, ViewController } from 'ionic-angular';
import { AddonMessagesProvider } from '../../providers/messages';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
/**
* Page that displays the list of conversations, including group conversations.
*/
@IonicPage({ segment: 'addon-messages-conversation-info' })
@Component({
selector: 'page-addon-messages-conversation-info',
templateUrl: 'conversation-info.html',
})
export class AddonMessagesConversationInfoPage implements OnInit {
loaded = false;
conversation: any;
members = [];
canLoadMore = false;
protected conversationId: number;
constructor(private messagesProvider: AddonMessagesProvider, private domUtils: CoreDomUtilsProvider, navParams: NavParams,
protected viewCtrl: ViewController) {
this.conversationId = navParams.get('conversationId');
}
/**
* Component loaded.
*/
ngOnInit(): void {
this.fetchData().finally(() => {
this.loaded = true;
});
}
/**
* Fetch the required data.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchData(): Promise<any> {
// Get the conversation data first.
return this.messagesProvider.getConversation(this.conversationId, false, false, 0, 0).then((conversation) => {
this.conversation = conversation;
// Now get the members.
return this.fetchMembers();
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error getting members.');
});
}
/**
* Get conversation members.
*
* @param {boolean} [loadingMore} Whether we are loading more data or just the first ones.
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchMembers(loadingMore?: boolean): Promise<any> {
const limitFrom = loadingMore ? this.members.length : 0;
return this.messagesProvider.getConversationMembers(this.conversationId, limitFrom).then((data) => {
if (loadingMore) {
this.members = this.members.concat(data.members);
} else {
this.members = data.members;
}
this.canLoadMore = data.canLoadMore;
});
}
/**
* Function to load more members.
*
* @param {any} [infiniteComplete] Infinite scroll complete function. Only used from core-infinite-loading.
* @return {Promise<any>} Resolved when done.
*/
loadMoreMembers(infiniteComplete?: any): Promise<any> {
return this.fetchMembers(true).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error getting members.');
this.canLoadMore = false;
}).finally(() => {
infiniteComplete && infiniteComplete();
});
}
/**
* Refresh the data.
*
* @param {any} [refresher] Refresher.
* @return {Promise<any>} Promise resolved when done.
*/
refreshData(refresher?: any): Promise<any> {
const promises = [];
promises.push(this.messagesProvider.invalidateConversation(this.conversationId));
promises.push(this.messagesProvider.invalidateConversationMembers(this.conversationId));
return Promise.all(promises).then(() => {
return this.fetchData().finally(() => {
refresher && refresher.complete();
});
});
}
/**
* Close modal.
*
* @param {number} [userId] User conversation to load.
*/
closeModal(userId?: number): void {
this.viewCtrl.dismiss(userId);
}
}

View File

@ -56,6 +56,7 @@
</ion-content>
<ion-footer color="light" class="footer-adjustable" *ngIf="!conversationId || conversation">
<ion-toolbar color="light" position="bottom">
<!-- @todo: Check if the user can send messages. -->
<core-send-message-form (onSubmit)="sendMessage($event)" [showKeyboard]="showKeyboard" [placeholder]="'addon.messages.newmessage' | translate" (onResize)="resizeContent()"></core-send-message-form>
</ion-toolbar>
</ion-footer>

View File

@ -13,7 +13,7 @@
// limitations under the License.
import { Component, OnDestroy, ViewChild, Optional } from '@angular/core';
import { IonicPage, NavParams, NavController, Content } from 'ionic-angular';
import { IonicPage, NavParams, NavController, Content, ModalController } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreEventsProvider } from '@providers/events';
import { CoreSitesProvider } from '@providers/sites';
@ -80,7 +80,9 @@ export class AddonMessagesDiscussionPage implements OnDestroy {
private userProvider: CoreUserProvider, private navCtrl: NavController, private messagesSync: AddonMessagesSyncProvider,
private domUtils: CoreDomUtilsProvider, private messagesProvider: AddonMessagesProvider, logger: CoreLoggerProvider,
private utils: CoreUtilsProvider, private appProvider: CoreAppProvider, private translate: TranslateService,
@Optional() private svComponent: CoreSplitViewComponent, private messagesOffline: AddonMessagesOfflineProvider) {
@Optional() private svComponent: CoreSplitViewComponent, private messagesOffline: AddonMessagesOfflineProvider,
private modalCtrl: ModalController) {
this.siteId = sitesProvider.getCurrentSiteId();
this.currentUserId = sitesProvider.getCurrentSiteUserId();
this.groupMessagingEnabled = this.messagesProvider.isGroupMessagingEnabled();
@ -351,7 +353,7 @@ export class AddonMessagesDiscussionPage implements OnDestroy {
});
this.loadMessages(messages);
} else if (error.errorcode != 'conversationdoesntexist') {
} else if (error.errorcode != 'errorconversationdoesnotexist') {
// Display the error.
return Promise.reject(error);
}
@ -913,8 +915,26 @@ export class AddonMessagesDiscussionPage implements OnDestroy {
*/
viewInfo(): void {
if (this.isGroup) {
// @todo
// Display the group information.
const modal = this.modalCtrl.create('AddonMessagesConversationInfoPage', {
conversationId: this.conversationId
});
modal.present();
modal.onDidDismiss((userId) => {
if (typeof userId != 'undefined') {
// Open user conversation.
if (this.svComponent) {
// Notify the left pane to load it, this way the right conversation will be highlighted.
this.eventsProvider.trigger(AddonMessagesProvider.OPEN_CONVERSATION_EVENT, {userId: userId}, this.siteId);
} else {
// Open the discussion in a new view.
this.navCtrl.push('AddonMessagesDiscussionPage', {userId: userId});
}
}
});
} else {
// Open the user profile.
const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl;
navCtrl.push('CoreUserProfilePage', { userId: this.userId });
}

View File

@ -105,7 +105,7 @@
<!-- Template to render a list of conversations. -->
<ng-template #conversationsTemplate let-conversations="conversations" let-avatarOptional="avatarOptional">
<a ion-item text-wrap *ngFor="let conversation of conversations" [title]="conversation.name" detail-none (click)="gotoConversation(conversation.id, conversation.userid)" [class.core-split-item-selected]="(conversation.id && conversation.id == selectedConversationId) || (conversation.userid && conversation.userid == selectedUserId)">
<a ion-item text-wrap *ngFor="let conversation of conversations" [title]="conversation.name" detail-none (click)="gotoConversation(conversation.id, conversation.userid)" [class.core-split-item-selected]="(conversation.id && conversation.id == selectedConversationId) || (conversation.userid && conversation.userid == selectedUserId)" id="addon-message-conversation-{{ conversation.id ? conversation.id : 'user-' + conversation.userid }}">
<ion-avatar item-start *ngIf="conversation.imageurl || !avatarOptional">
<img src="{{conversation.imageurl}}" [alt]="conversation.name" core-external-content onError="this.src='assets/img/user-avatar.png'">
<!-- @todo: Display connection status.

View File

@ -71,6 +71,7 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
protected appResumeSubscription: any;
protected readChangedObserver: any;
protected cronObserver: any;
protected openConversationObserver: any;
constructor(private eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, translate: TranslateService,
private messagesProvider: AddonMessagesProvider, private domUtils: CoreDomUtilsProvider, navParams: NavParams,
@ -87,15 +88,7 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
// Update conversations when new message is received.
this.newMessagesObserver = eventsProvider.on(AddonMessagesProvider.NEW_MESSAGE_EVENT, (data) => {
// Search the conversation to update.
let conversation;
if (data.conversationId) {
conversation = this.findConversation(data.conversationId);
} else if (data.userId) {
conversation = this.individual.conversations && this.individual.conversations.find((conv) => {
return conv.userid == data.userId;
});
}
const conversation = this.findConversation(data.conversationId, data.userId);
if (typeof conversation == 'undefined') {
// Probably a new conversation, refresh the list.
@ -111,13 +104,8 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
conversation.lastmessagedate = data.timecreated / 1000;
// Sort the affected list.
if (conversation.isfavourite) {
this.favourites.conversations = this.messagesProvider.sortConversations(this.favourites.conversations);
} else if (conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP) {
this.group.conversations = this.messagesProvider.sortConversations(this.group.conversations);
} else {
this.individual.conversations = this.messagesProvider.sortConversations(this.individual.conversations);
}
const option = this.getConversationOption(conversation);
option.conversations = this.messagesProvider.sortConversations(option.conversations);
if (isNewer) {
// The last message is newer than the previous one, scroll to top to keep viewing the conversation.
@ -146,6 +134,13 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
this.refreshData();
}, this.siteId);
// Load a discussion if we receive an event to do so.
this.openConversationObserver = eventsProvider.on(AddonMessagesProvider.OPEN_CONVERSATION_EVENT, (data) => {
if (data.conversationId || data.userId) {
this.gotoConversation(data.conversationId, data.userId, undefined, true);
}
}, this.siteId);
// Refresh the view when the app is resumed.
this.appResumeSubscription = platform.resume.subscribe(() => {
if (!this.loaded) {
@ -227,6 +222,18 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
if (typeof this.favourites.expanded == 'undefined') {
// The expanded status hasn't been initialized. Do it now.
if (this.conversationId) {
// A certain conversation should be opened, expand its option.
const conversation = this.findConversation(this.conversationId);
if (conversation) {
const option = this.getConversationOption(conversation);
option.expanded = true;
return;
}
}
// No conversation specified or not found, determine which one should be expanded.
this.favourites.expanded = this.favourites.count != 0;
this.group.expanded = this.favourites.count == 0 && this.group.count != 0;
this.individual.expanded = this.favourites.count == 0 && this.group.count == 0;
@ -265,15 +272,22 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
* Find a conversation in the list of loaded conversations.
*
* @param {number} conversationId The conversation ID to search.
* @param {number} userId User ID to search (if no conversationId).
* @return {any} Conversation.
*/
protected findConversation(conversationId: number): any {
const conversations = (this.favourites.conversations || []).concat(this.group.conversations || [])
.concat(this.individual.conversations || []);
protected findConversation(conversationId: number, userId?: number): any {
if (conversationId) {
const conversations = (this.favourites.conversations || []).concat(this.group.conversations || [])
.concat(this.individual.conversations || []);
return conversations.find((conv) => {
return conv.id == conversationId;
});
return conversations.find((conv) => {
return conv.id == conversationId;
});
} else if (this.individual.conversations) {
return this.individual.conversations.find((conv) => {
return conv.userid == userId;
});
}
}
/**
@ -289,8 +303,9 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
* @param {number} conversationId Conversation Id to load.
* @param {number} userId User of the conversation. Only if there is no conversationId.
* @param {number} [messageId] Message to scroll after loading the discussion. Used when searching.
* @param {boolean} [scrollToConversation] Whether to scroll to the conversation.
*/
gotoConversation(conversationId: number, userId?: number, messageId?: number): void {
gotoConversation(conversationId: number, userId?: number, messageId?: number, scrollToConversation?: boolean): void {
this.selectedConversationId = conversationId;
this.selectedUserId = userId;
@ -302,6 +317,23 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
params['message'] = messageId;
}
this.splitviewCtrl.push('AddonMessagesDiscussionPage', params);
if (scrollToConversation) {
// Search the conversation.
const conversation = this.findConversation(conversationId, userId);
if (conversation) {
// First expand the option if it isn't expanded.
const option = this.getConversationOption(conversation);
this.expandOption(option);
// Wait for the view to expand the option.
setTimeout(() => {
// Now scroll to the conversation.
this.domUtils.scrollToElementBySelector(this.content, '#addon-message-conversation-' +
(conversation.id ? conversation.id : 'user-' + conversation.userid));
});
}
}
}
/**
@ -358,9 +390,7 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
}
} else {
// Its a new conversation. Check if we already created it (there is more than one message for the same user).
const conversation = this.individual.conversations.find((conv) => {
return conv.userid == message.touserid;
});
const conversation = this.findConversation(undefined, message.touserid);
message.text = message.smallmessage;
@ -397,13 +427,8 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
* @param {any} conversation Offline conversation to add.
*/
protected addOfflineConversation(conversation: any): void {
if (conversation.isfavourite) {
this.favourites.conversations.unshift(conversation);
} else if (conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP) {
this.group.conversations.unshift(conversation);
} else {
this.individual.conversations.unshift(conversation);
}
const option = this.getConversationOption(conversation);
option.conversations.unshift(conversation);
}
/**
@ -419,6 +444,22 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
conversation.sentfromcurrentuser = true;
}
/**
* Given a conversation, return its option (favourites, group, individual).
*
* @param {any} conversation Conversation to check.
* @return {any} Option object.
*/
protected getConversationOption(conversation: any): any {
if (conversation.isfavourite) {
return this.favourites;
} else if (conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP) {
return this.group;
} else {
return this.individual;
}
}
/**
* Refresh the data.
*
@ -447,14 +488,23 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
// Already expanded, close it.
option.expanded = false;
} else {
// Collapse all and expand the clicked one.
this.favourites.expanded = false;
this.group.expanded = false;
this.individual.expanded = false;
option.expanded = true;
this.expandOption(option);
}
}
/**
* Expand a certain option.
*
* @param {any} option The option to expand.
*/
protected expandOption(option: any): void {
// Collapse all and expand the right one.
this.favourites.expanded = false;
this.group.expanded = false;
this.individual.expanded = false;
option.expanded = true;
}
/**
* Clear search and show conversations again.
*/
@ -497,5 +547,6 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
this.pushObserver && this.pushObserver.unsubscribe();
this.readChangedObserver && this.readChangedObserver.off();
this.cronObserver && this.cronObserver.off();
this.openConversationObserver && this.openConversationObserver.off();
}
}

View File

@ -33,6 +33,7 @@ export class AddonMessagesProvider {
static NEW_MESSAGE_EVENT = 'addon_messages_new_message_event';
static READ_CHANGED_EVENT = 'addon_messages_read_changed_event';
static READ_CRON_EVENT = 'addon_messages_read_cron_event';
static OPEN_CONVERSATION_EVENT = 'addon_messages_open_conversation_event'; // Notify that a conversation should be opened.
static SPLIT_VIEW_LOAD_EVENT = 'addon_messages_split_view_load_event';
static POLL_INTERVAL = 10000;
static PUSH_SIMULATION_COMPONENT = 'AddonMessagesPushSimulation';
@ -250,6 +251,17 @@ export class AddonMessagesProvider {
return this.ROOT_CACHE_KEY + 'conversationBetweenUsers:' + userId + ':' + otherUserId;
}
/**
* Get cache key for get conversation members.
*
* @param {number} userId User ID.
* @param {number} conversationId Conversation ID.
* @return {string} Cache key.
*/
protected getCacheKeyForConversationMembers(userId: number, conversationId: number): string {
return this.ROOT_CACHE_KEY + 'conversationMembers:' + userId + ':' + conversationId;
}
/**
* Get cache key for get conversation messages.
*
@ -463,6 +475,54 @@ export class AddonMessagesProvider {
});
}
/**
* Get a conversation members.
*
* @param {number} conversationId Conversation ID to fetch.
* @param {number} [limitFrom=0] Offset for members list.
* @param {number} [limitTo] Limit of members.
* @param {string} [siteId] Site ID. If not defined, use current site.
* @param {number} [userId] User ID. If not defined, current user in the site.
* @return {Promise<any>} Promise resolved with the response.
* @since 3.6
*/
getConversationMembers(conversationId: number, limitFrom: number = 0, limitTo?: number, includeContactRequests?: boolean,
siteId?: string, userId?: number): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
userId = userId || site.getUserId();
if (typeof limitTo == 'undefined' || limitTo === null) {
limitTo = this.LIMIT_MESSAGES;
}
const preSets = {
cacheKey: this.getCacheKeyForConversationMembers(userId, conversationId)
},
params: any = {
userid: userId,
conversationid: conversationId,
limitfrom: limitFrom,
limitnum: limitTo < 1 ? limitTo : limitTo + 1, // If there is a limit, get 1 more than requested.
includecontactrequests: includeContactRequests ? 1 : 0
};
return site.read('core_message_get_conversation_members', params, preSets).then((members) => {
const result: any = {};
if (limitTo < 1) {
result.canLoadMore = false;
result.members = members;
} else {
result.canLoadMore = members.length > limitTo;
result.members = members.slice(0, limitTo);
}
return result;
});
});
}
/**
* Get a conversation by the conversation ID.
*
@ -1071,6 +1131,22 @@ export class AddonMessagesProvider {
});
}
/**
* Invalidate conversation members cache.
*
* @param {number} conversationId Conversation ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @param {number} [userId] User ID. If not defined, current user in the site.
* @return {Promise<any>} Resolved when done.
*/
invalidateConversationMembers(conversationId: number, siteId?: string, userId?: number): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
userId = userId || site.getUserId();
return site.invalidateWsCacheForKey(this.getCacheKeyForConversationMembers(userId, conversationId));
});
}
/**
* Invalidate conversation messages cache.
*

View File

@ -178,6 +178,7 @@
"addon.messages.nogroupmessages": "No group messages",
"addon.messages.nomessages": "No messages",
"addon.messages.nousersfound": "No users found",
"addon.messages.numparticipants": "{{$a}} participants",
"addon.messages.removecontact": "Remove contact",
"addon.messages.removecontactconfirm": "Contact will be removed from your contacts list.",
"addon.messages.showdeletemessages": "Show delete messages",