MOBILE-2631 message: Allow adding/removing favourites
parent
62a37720da
commit
f22f33dad4
|
@ -148,6 +148,7 @@
|
||||||
"addon.files.sitefiles": "moodle",
|
"addon.files.sitefiles": "moodle",
|
||||||
"addon.messageoutput_airnotifier.processorsettingsdesc": "local_moodlemobileapp",
|
"addon.messageoutput_airnotifier.processorsettingsdesc": "local_moodlemobileapp",
|
||||||
"addon.messages.addcontact": "message",
|
"addon.messages.addcontact": "message",
|
||||||
|
"addon.messages.addtofavourites": "message",
|
||||||
"addon.messages.blocknoncontacts": "message",
|
"addon.messages.blocknoncontacts": "message",
|
||||||
"addon.messages.blockuser": "message",
|
"addon.messages.blockuser": "message",
|
||||||
"addon.messages.blockuserconfirm": "message",
|
"addon.messages.blockuserconfirm": "message",
|
||||||
|
@ -181,6 +182,7 @@
|
||||||
"addon.messages.numparticipants": "message",
|
"addon.messages.numparticipants": "message",
|
||||||
"addon.messages.removecontact": "message",
|
"addon.messages.removecontact": "message",
|
||||||
"addon.messages.removecontactconfirm": "local_moodlemobileapp",
|
"addon.messages.removecontactconfirm": "local_moodlemobileapp",
|
||||||
|
"addon.messages.removefromfavourites": "message",
|
||||||
"addon.messages.showdeletemessages": "local_moodlemobileapp",
|
"addon.messages.showdeletemessages": "local_moodlemobileapp",
|
||||||
"addon.messages.type_blocked": "local_moodlemobileapp",
|
"addon.messages.type_blocked": "local_moodlemobileapp",
|
||||||
"addon.messages.type_offline": "local_moodlemobileapp",
|
"addon.messages.type_offline": "local_moodlemobileapp",
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"addcontact": "Add contact",
|
"addcontact": "Add contact",
|
||||||
|
"addtofavourites": "Star",
|
||||||
"blocknoncontacts": "Prevent non-contacts from messaging me",
|
"blocknoncontacts": "Prevent non-contacts from messaging me",
|
||||||
"blockuser": "Block user",
|
"blockuser": "Block user",
|
||||||
"blockuserconfirm": "Are you sure you want to block {{$a}}?",
|
"blockuserconfirm": "Are you sure you want to block {{$a}}?",
|
||||||
|
@ -33,6 +34,7 @@
|
||||||
"numparticipants": "{{$a}} participants",
|
"numparticipants": "{{$a}} participants",
|
||||||
"removecontact": "Remove contact",
|
"removecontact": "Remove contact",
|
||||||
"removecontactconfirm": "Contact will be removed from your contacts list.",
|
"removecontactconfirm": "Contact will be removed from your contacts list.",
|
||||||
|
"removefromfavourites": "Unstar",
|
||||||
"showdeletemessages": "Show delete messages",
|
"showdeletemessages": "Show delete messages",
|
||||||
"type_blocked": "Blocked",
|
"type_blocked": "Blocked",
|
||||||
"type_offline": "Offline",
|
"type_offline": "Offline",
|
||||||
|
|
|
@ -3,14 +3,16 @@
|
||||||
<ion-title>
|
<ion-title>
|
||||||
<img *ngIf="conversationImage" class="core-bar-button-image" [src]="conversationImage">
|
<img *ngIf="conversationImage" class="core-bar-button-image" [src]="conversationImage">
|
||||||
<core-format-text [text]="title"></core-format-text>
|
<core-format-text [text]="title"></core-format-text>
|
||||||
|
<core-icon *ngIf="conversation && conversation.isfavourite" name="fa-star"></core-icon>
|
||||||
</ion-title>
|
</ion-title>
|
||||||
<ion-buttons end></ion-buttons>
|
<ion-buttons end></ion-buttons>
|
||||||
</ion-navbar>
|
</ion-navbar>
|
||||||
<core-navbar-buttons end>
|
<core-navbar-buttons end>
|
||||||
<core-context-menu>
|
<core-context-menu>
|
||||||
<core-context-menu-item [hidden]="!showInfo || isGroup" [priority]="1000" [content]="'addon.messages.info' | translate" (action)="viewInfo()" [iconAction]="'information-circle'"></core-context-menu-item>
|
<core-context-menu-item [hidden]="!showInfo || isGroup" [priority]="1000" [content]="'addon.messages.info' | translate" (action)="viewInfo()" [iconAction]="'information-circle'"></core-context-menu-item>
|
||||||
<core-context-menu-item [hidden]="!showInfo || !isGroup" [priority]="999" [content]="'addon.messages.groupinfo' | translate" (action)="viewInfo()" [iconAction]="'information-circle'"></core-context-menu-item>
|
<core-context-menu-item [hidden]="!showInfo || !isGroup" [priority]="1000" [content]="'addon.messages.groupinfo' | translate" (action)="viewInfo()" [iconAction]="'information-circle'"></core-context-menu-item>
|
||||||
<core-context-menu-item [hidden]="!canDelete" [priority]="800" [content]="'addon.messages.showdeletemessages' | translate" (action)="toggleDelete()" [iconAction]="'trash'"></core-context-menu-item>
|
<core-context-menu-item [hidden]="!groupMessagingEnabled || !conversation" [priority]="800" [content]="(conversation && conversation.isfavourite ? 'addon.messages.removefromfavourites' : 'addon.messages.addtofavourites') | translate" (action)="changeFavourite($event)" [iconAction]="favouriteIcon" [closeOnClick]="false"></core-context-menu-item>
|
||||||
|
<core-context-menu-item [hidden]="!canDelete" [priority]="400" [content]="'addon.messages.showdeletemessages' | translate" (action)="toggleDelete()" [iconAction]="'trash'"></core-context-menu-item>
|
||||||
</core-context-menu>
|
</core-context-menu>
|
||||||
</core-navbar-buttons>
|
</core-navbar-buttons>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
|
|
|
@ -75,6 +75,7 @@ export class AddonMessagesDiscussionPage implements OnDestroy {
|
||||||
groupMessagingEnabled: boolean;
|
groupMessagingEnabled: boolean;
|
||||||
isGroup = false;
|
isGroup = false;
|
||||||
members: any = {}; // Members that wrote a message, indexed by ID.
|
members: any = {}; // Members that wrote a message, indexed by ID.
|
||||||
|
favouriteIcon = 'fa-star';
|
||||||
|
|
||||||
constructor(private eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, navParams: NavParams,
|
constructor(private eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, navParams: NavParams,
|
||||||
private userProvider: CoreUserProvider, private navCtrl: NavController, private messagesSync: AddonMessagesSyncProvider,
|
private userProvider: CoreUserProvider, private navCtrl: NavController, private messagesSync: AddonMessagesSyncProvider,
|
||||||
|
@ -332,51 +333,67 @@ export class AddonMessagesDiscussionPage implements OnDestroy {
|
||||||
* @return {Promise<boolean>} Promise resolved with a boolean: whether the conversation exists or not.
|
* @return {Promise<boolean>} Promise resolved with a boolean: whether the conversation exists or not.
|
||||||
*/
|
*/
|
||||||
protected getConversation(conversationId: number, userId: number): Promise<boolean> {
|
protected getConversation(conversationId: number, userId: number): Promise<boolean> {
|
||||||
let promise;
|
let promise,
|
||||||
|
fallbackConversation;
|
||||||
|
|
||||||
|
// Try to get the conversationId if we don't have it.
|
||||||
if (conversationId) {
|
if (conversationId) {
|
||||||
// Retrieve the conversation. Invalidate data first to get the right unreadcount.
|
promise = Promise.resolve(conversationId);
|
||||||
promise = this.messagesProvider.invalidateConversation(conversationId).then(() => {
|
|
||||||
return this.messagesProvider.getConversation(conversationId);
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// We don't have the conversation ID, check if it exists.
|
promise = this.messagesProvider.getConversationBetweenUsers(userId).then((conversation) => {
|
||||||
promise = this.messagesProvider.getConversationBetweenUsers(userId).catch((error) => {
|
fallbackConversation = conversation;
|
||||||
|
|
||||||
// Probably conversation does not exist or user is offline. Try to load offline messages.
|
return conversation.id;
|
||||||
return this.messagesOffline.getMessages(userId).then((messages) => {
|
|
||||||
if (messages && messages.length) {
|
|
||||||
// We have offline messages, this probably means that the conversation didn't exist. Don't display error.
|
|
||||||
messages.forEach((message) => {
|
|
||||||
message.pending = true;
|
|
||||||
message.text = message.smallmessage;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.loadMessages(messages);
|
|
||||||
} else if (error.errorcode != 'errorconversationdoesnotexist') {
|
|
||||||
// Display the error.
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return promise.then((conversation) => {
|
return promise.then((conversationId) => {
|
||||||
this.conversation = conversation;
|
// Retrieve the conversation. Invalidate data first to get the right unreadcount.
|
||||||
|
return this.messagesProvider.invalidateConversation(conversationId).catch(() => {
|
||||||
if (conversation) {
|
// Ignore errors.
|
||||||
this.conversationId = conversation.id;
|
}).then(() => {
|
||||||
this.title = conversation.name;
|
return this.messagesProvider.getConversation(conversationId);
|
||||||
this.conversationImage = conversation.imageurl;
|
}).catch((error) => {
|
||||||
this.isGroup = conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP;
|
// Get conversation failed, use the fallback one if we have it.
|
||||||
if (!this.isGroup) {
|
if (fallbackConversation) {
|
||||||
this.userId = conversation.userid;
|
return fallbackConversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return Promise.reject(error);
|
||||||
} else {
|
}).then((conversation) => {
|
||||||
return false;
|
this.conversation = conversation;
|
||||||
}
|
|
||||||
|
if (conversation) {
|
||||||
|
this.conversationId = conversation.id;
|
||||||
|
this.title = conversation.name;
|
||||||
|
this.conversationImage = conversation.imageurl;
|
||||||
|
this.isGroup = conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP;
|
||||||
|
this.favouriteIcon = conversation.isfavourite ? 'fa-star-o' : 'fa-star';
|
||||||
|
if (!this.isGroup) {
|
||||||
|
this.userId = conversation.userid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, (error) => {
|
||||||
|
// Probably conversation does not exist or user is offline. Try to load offline messages.
|
||||||
|
return this.messagesOffline.getMessages(userId).then((messages) => {
|
||||||
|
if (messages && messages.length) {
|
||||||
|
// We have offline messages, this probably means that the conversation didn't exist. Don't display error.
|
||||||
|
messages.forEach((message) => {
|
||||||
|
message.pending = true;
|
||||||
|
message.text = message.smallmessage;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.loadMessages(messages);
|
||||||
|
} else if (error.errorcode != 'errorconversationdoesnotexist') {
|
||||||
|
// Display the error.
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -940,6 +957,33 @@ export class AddonMessagesDiscussionPage implements OnDestroy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the favourite state of the current conversation.
|
||||||
|
*
|
||||||
|
* @param {Function} [done] Function to call when done.
|
||||||
|
*/
|
||||||
|
changeFavourite(done?: () => void): void {
|
||||||
|
this.favouriteIcon = 'spinner';
|
||||||
|
|
||||||
|
this.messagesProvider.setFavouriteConversation(this.conversation.id, !this.conversation.isfavourite).then(() => {
|
||||||
|
this.conversation.isfavourite = !this.conversation.isfavourite;
|
||||||
|
|
||||||
|
// Get the conversation data so it's cached. Don't block the user for this.
|
||||||
|
this.messagesProvider.getConversation(this.conversation.id);
|
||||||
|
|
||||||
|
this.eventsProvider.trigger(AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, {
|
||||||
|
conversationId: this.conversation.id,
|
||||||
|
action: 'favourite',
|
||||||
|
value: this.conversation.isfavourite
|
||||||
|
}, this.siteId);
|
||||||
|
}).catch((error) => {
|
||||||
|
this.domUtils.showErrorModalDefault(error, 'Error changing favourite state.');
|
||||||
|
}).finally(() => {
|
||||||
|
this.favouriteIcon = this.conversation.isfavourite ? 'fa-star-o' : 'fa-star';
|
||||||
|
done && done();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page destroyed.
|
* Page destroyed.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -73,6 +73,7 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
|
||||||
protected readChangedObserver: any;
|
protected readChangedObserver: any;
|
||||||
protected cronObserver: any;
|
protected cronObserver: any;
|
||||||
protected openConversationObserver: any;
|
protected openConversationObserver: any;
|
||||||
|
protected updateConversationListObserver: any;
|
||||||
|
|
||||||
constructor(private eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, translate: TranslateService,
|
constructor(private eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, translate: TranslateService,
|
||||||
private messagesProvider: AddonMessagesProvider, private domUtils: CoreDomUtilsProvider, navParams: NavParams,
|
private messagesProvider: AddonMessagesProvider, private domUtils: CoreDomUtilsProvider, navParams: NavParams,
|
||||||
|
@ -115,22 +116,22 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
|
||||||
}
|
}
|
||||||
}, this.siteId);
|
}, this.siteId);
|
||||||
|
|
||||||
// Update discussions when a message is read.
|
// Update conversations when a message is read.
|
||||||
this.readChangedObserver = eventsProvider.on(AddonMessagesProvider.READ_CHANGED_EVENT, (data) => {
|
this.readChangedObserver = eventsProvider.on(AddonMessagesProvider.READ_CHANGED_EVENT, (data) => {
|
||||||
if (data.conversationId) {
|
if (data.conversationId) {
|
||||||
const conversation = this.findConversation(data.conversationId);
|
const conversation = this.findConversation(data.conversationId);
|
||||||
|
|
||||||
if (typeof conversation != 'undefined') {
|
if (typeof conversation != 'undefined') {
|
||||||
// A discussion has been read reset counter.
|
// A conversation has been read reset counter.
|
||||||
conversation.unreadcount = 0;
|
conversation.unreadcount = 0;
|
||||||
|
|
||||||
// Discussions changed, invalidate them.
|
// Conversations changed, invalidate them.
|
||||||
this.messagesProvider.invalidateConversations();
|
this.messagesProvider.invalidateConversations();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, this.siteId);
|
}, this.siteId);
|
||||||
|
|
||||||
// Update discussions when cron read is executed.
|
// Update conversations when cron read is executed.
|
||||||
this.cronObserver = eventsProvider.on(AddonMessagesProvider.READ_CRON_EVENT, (data) => {
|
this.cronObserver = eventsProvider.on(AddonMessagesProvider.READ_CRON_EVENT, (data) => {
|
||||||
this.refreshData();
|
this.refreshData();
|
||||||
}, this.siteId);
|
}, this.siteId);
|
||||||
|
@ -153,6 +154,11 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update conversations if we receive an event to do so.
|
||||||
|
this.updateConversationListObserver = eventsProvider.on(AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, () => {
|
||||||
|
this.refreshData();
|
||||||
|
}, this.siteId);
|
||||||
|
|
||||||
// If a message push notification is received, refresh the view.
|
// If a message push notification is received, refresh the view.
|
||||||
this.pushObserver = pushNotificationsDelegate.on('receive').subscribe((notification) => {
|
this.pushObserver = pushNotificationsDelegate.on('receive').subscribe((notification) => {
|
||||||
// New message received. If it's from current site, refresh the data.
|
// New message received. If it's from current site, refresh the data.
|
||||||
|
@ -551,5 +557,6 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
|
||||||
this.readChangedObserver && this.readChangedObserver.off();
|
this.readChangedObserver && this.readChangedObserver.off();
|
||||||
this.cronObserver && this.cronObserver.off();
|
this.cronObserver && this.cronObserver.off();
|
||||||
this.openConversationObserver && this.openConversationObserver.off();
|
this.openConversationObserver && this.openConversationObserver.off();
|
||||||
|
this.updateConversationListObserver && this.updateConversationListObserver.off();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,6 +35,7 @@ export class AddonMessagesProvider {
|
||||||
static READ_CRON_EVENT = 'addon_messages_read_cron_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 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 SPLIT_VIEW_LOAD_EVENT = 'addon_messages_split_view_load_event';
|
||||||
|
static UPDATE_CONVERSATION_LIST_EVENT = 'addon_messages_update_conversation_list_event';
|
||||||
static POLL_INTERVAL = 10000;
|
static POLL_INTERVAL = 10000;
|
||||||
static PUSH_SIMULATION_COMPONENT = 'AddonMessagesPushSimulation';
|
static PUSH_SIMULATION_COMPONENT = 'AddonMessagesPushSimulation';
|
||||||
|
|
||||||
|
@ -1114,7 +1115,7 @@ export class AddonMessagesProvider {
|
||||||
return this.sitesProvider.getSite(siteId).then((site) => {
|
return this.sitesProvider.getSite(siteId).then((site) => {
|
||||||
userId = userId || site.getUserId();
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
return site.invalidateWsCacheForKey(this.getCacheKeyForConversation(conversationId, userId));
|
return site.invalidateWsCacheForKey(this.getCacheKeyForConversation(userId, conversationId));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1697,6 +1698,53 @@ export class AddonMessagesProvider {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or unset a conversation as favourite.
|
||||||
|
*
|
||||||
|
* @param {number} conversationId Conversation ID.
|
||||||
|
* @param {boolean} set Whether to set or unset it as favourite.
|
||||||
|
* @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>} Resolved when done.
|
||||||
|
*/
|
||||||
|
setFavouriteConversation(conversationId: number, set: boolean, siteId?: string, userId?: number): Promise<any> {
|
||||||
|
return this.setFavouriteConversations([conversationId], set, siteId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or unset some conversations as favourites.
|
||||||
|
*
|
||||||
|
* @param {number[]} conversations Conversation IDs.
|
||||||
|
* @param {boolean} set Whether to set or unset them as favourites.
|
||||||
|
* @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>} Resolved when done.
|
||||||
|
*/
|
||||||
|
setFavouriteConversations(conversations: number[], set: boolean, siteId?: string, userId?: number): Promise<any> {
|
||||||
|
return this.sitesProvider.getSite(siteId).then((site) => {
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
userid: userId,
|
||||||
|
conversations: conversations
|
||||||
|
},
|
||||||
|
wsName = set ? 'core_message_set_favourite_conversations' : 'core_message_unset_favourite_conversations';
|
||||||
|
|
||||||
|
return site.write(wsName, params).then(() => {
|
||||||
|
// Invalidate the conversations data.
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
conversations.forEach((conversationId) => {
|
||||||
|
promises.push(this.invalidateConversation(conversationId, site.getId(), userId));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(promises).catch(() => {
|
||||||
|
// Ignore errors.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper method to sort conversations by last message time.
|
* Helper method to sort conversations by last message time.
|
||||||
*
|
*
|
||||||
|
|
|
@ -148,6 +148,7 @@
|
||||||
"addon.files.sitefiles": "Site files",
|
"addon.files.sitefiles": "Site files",
|
||||||
"addon.messageoutput_airnotifier.processorsettingsdesc": "Configure devices",
|
"addon.messageoutput_airnotifier.processorsettingsdesc": "Configure devices",
|
||||||
"addon.messages.addcontact": "Add contact",
|
"addon.messages.addcontact": "Add contact",
|
||||||
|
"addon.messages.addtofavourites": "Star",
|
||||||
"addon.messages.blocknoncontacts": "Prevent non-contacts from messaging me",
|
"addon.messages.blocknoncontacts": "Prevent non-contacts from messaging me",
|
||||||
"addon.messages.blockuser": "Block user",
|
"addon.messages.blockuser": "Block user",
|
||||||
"addon.messages.blockuserconfirm": "Are you sure you want to block {{$a}}?",
|
"addon.messages.blockuserconfirm": "Are you sure you want to block {{$a}}?",
|
||||||
|
@ -181,6 +182,7 @@
|
||||||
"addon.messages.numparticipants": "{{$a}} participants",
|
"addon.messages.numparticipants": "{{$a}} participants",
|
||||||
"addon.messages.removecontact": "Remove contact",
|
"addon.messages.removecontact": "Remove contact",
|
||||||
"addon.messages.removecontactconfirm": "Contact will be removed from your contacts list.",
|
"addon.messages.removecontactconfirm": "Contact will be removed from your contacts list.",
|
||||||
|
"addon.messages.removefromfavourites": "Unstar",
|
||||||
"addon.messages.showdeletemessages": "Show delete messages",
|
"addon.messages.showdeletemessages": "Show delete messages",
|
||||||
"addon.messages.type_blocked": "Blocked",
|
"addon.messages.type_blocked": "Blocked",
|
||||||
"addon.messages.type_offline": "Offline",
|
"addon.messages.type_offline": "Offline",
|
||||||
|
|
Loading…
Reference in New Issue