diff --git a/openedx/features/wikimedia_features/messenger/admin.py b/openedx/features/wikimedia_features/messenger/admin.py new file mode 100644 index 000000000000..1be3bfac27b5 --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/admin.py @@ -0,0 +1,38 @@ +""" +Admin registration for Messenger. +""" +from django.contrib import admin +from django.utils.translation import ugettext_lazy as _ + +from openedx.features.wikimedia_features.messenger.models import ( + Message, Inbox +) + + +class MessageAdmin(admin.ModelAdmin): + """ + Admin config clearesult credit providers. + """ + search_fields = ('sender__username', 'receiver__username') + list_display = ('id', 'sender', 'receiver', 'message', 'created') + + +class InboxAdmin(admin.ModelAdmin): + """ + Admin config for clearesult credits offered by the courses. + """ + search_fields = ('sender', 'receiver') + list_display = ('id', 'sender', 'receiver', 'message', 'unread_count') + + def sender(self, obj): + return obj.last_message.sender.username + + def receiver(self, obj): + return obj.last_message.receiver.username + + def message(self, obj): + return obj.last_message.message[:20] + + +admin.site.register(Message, MessageAdmin) +admin.site.register(Inbox, InboxAdmin) diff --git a/openedx/features/wikimedia_features/messenger/api/__init__.py b/openedx/features/wikimedia_features/messenger/api/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/wikimedia_features/messenger/api/v0/__init__.py b/openedx/features/wikimedia_features/messenger/api/v0/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/wikimedia_features/messenger/api/v0/serializers.py b/openedx/features/wikimedia_features/messenger/api/v0/serializers.py new file mode 100644 index 000000000000..1ea8301545ba --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/api/v0/serializers.py @@ -0,0 +1,109 @@ +""" +Serializers for Messenger v0 API(s) +""" +from django.contrib.auth.models import User +from django.db import transaction +from django.utils.translation import ugettext as _ +from rest_framework import serializers +from openedx.features.wikimedia_features.messenger.models import Inbox, Message +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user + + +def validate_username(username): + try: + return User.objects.get(username=username) + except User.DoesNotExist: + raise serializers.ValidationError(_('User does not exist - invalid username {}'.format(username))) + + +class StringListField(serializers.ListField): + child = serializers.CharField() + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ('username',) + +class BulkMessageSerializer(serializers.Serializer): + receivers = StringListField() + message = serializers.CharField() + + def validate_receivers(self, receivers): + if not receivers: + raise serializers.ValidationError(_('receiver list can not empty.')) + users = [validate_username(username) for username in receivers] + return users + + def bulk_create(self, request=None): + created_messages = [] + if not request: + raise serializers.ValidationError(_('Missing request object.')) + receivers = self.validated_data.get('receivers') + message = self.validated_data.get('message') + with transaction.atomic(): + for user in receivers: + created_messages.append( + Message.objects.create(sender=request.user, receiver=user, message=message) + ) + return created_messages + + +class InboxSerializer(serializers.ModelSerializer): + with_user = serializers.SerializerMethodField() + request = None + + class Meta: + model = Inbox + fields = ('id', 'with_user', 'last_message', 'unread_count') + + def __init__(self, *args, **kwargs): + super(InboxSerializer, self).__init__(*args, **kwargs) + self.request = self.context.get('request') + + def get_with_user(self, obj): + if self.request: + if obj.last_message.sender != self.request.user: + return obj.last_message.sender.username + return obj.last_message.receiver.username + raise serializers.ValidationError( + _('Invalid request - request object not found.') + ) + + def to_representation(self, instance): + response = super().to_representation(instance) + with_user = User.objects.get(username=response.get('with_user')) + response['with_user_img'] = get_profile_image_urls_for_user(with_user, self.request).get('medium') + response['last_message'] =instance.last_message.message + + # if last message is send by login-user then unread count will be 0 + if self.request and instance.last_message.sender == self.request.user: + response['unread_count'] = 0 + + return response + + +class MessageSerializer(serializers.ModelSerializer): + receiver = serializers.CharField(source='receiver.username') + class Meta: + model = Message + fields = ('id', 'sender', 'receiver', 'message', 'created') + read_only_fields = ('id', 'created', 'sender') + + def validate_receiver(self, receiver): + return validate_username(receiver) + + def create(self, validated_data): + request = self.context.get('request') + validated_data['sender'] = request.user + validated_data['receiver'] = validated_data.get('receiver', {}).get('username') + return super().create(validated_data) + + def to_representation(self, instance): + response = super().to_representation(instance) + response['sender'] = instance.sender.username + response['created'] = instance.created.strftime('%x-%I:%M %p') + response['sender_img']= get_profile_image_urls_for_user( + instance.sender, self.context.get('request') + ).get('medium') + return response diff --git a/openedx/features/wikimedia_features/messenger/api/v0/urls.py b/openedx/features/wikimedia_features/messenger/api/v0/urls.py new file mode 100644 index 000000000000..1d0ad0fb7780 --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/api/v0/urls.py @@ -0,0 +1,55 @@ +""" +Urls for Messenger v0 API(s) +""" +from django.conf.urls import url + +from openedx.features.wikimedia_features.messenger.api.v0.views import ( + InboxView, ConversationView, MessageCreateView, UserSearchView, BulkMessageView +) + +app_name = 'messenger_api_v0' + + +urlpatterns = [ + url( + r'^bulk_message/$', + BulkMessageView.as_view({ + 'post': 'bulk_message' + }), + name="bulk_message" + ), + url( + r'^user/$', + UserSearchView.as_view({ + 'get': 'list' + }), + name="user_search" + ), + url( + r'^inbox/$', + InboxView.as_view({ + 'get': 'list' + }), + name="user_inbox_list" + ), + url( + r'^inbox/(?P\d+)/$', + InboxView.as_view({ + 'patch': 'partial_update', + 'get': 'retrieve' + }), + name="user_inbox_detail" + ), + url( + r'^conversation/$', + ConversationView.as_view({ + 'get': 'list', + }), + name="conversation_list" + ), + url( + r'^message/$', + MessageCreateView.as_view(), + name="message_create" + ), +] diff --git a/openedx/features/wikimedia_features/messenger/api/v0/views.py b/openedx/features/wikimedia_features/messenger/api/v0/views.py new file mode 100644 index 000000000000..977da1764915 --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/api/v0/views.py @@ -0,0 +1,190 @@ +""" +Views for Messenger v0 API(s) +""" +from django.contrib.auth.models import User +from django.utils.translation import ugettext as _ +from rest_framework import generics +from rest_framework import permissions +from rest_framework import viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.exceptions import NotFound, status +from rest_framework.pagination import PageNumberPagination +from rest_framework.response import Response + +from openedx.features.wikimedia_features.messenger.models import Inbox, Message +from openedx.features.wikimedia_features.messenger.api.v0.serializers import ( + InboxSerializer, MessageSerializer, UserSerializer, BulkMessageSerializer +) + + +class MesssengerResultsSetPagination(PageNumberPagination): + page_size = 15 + + def get_paginated_response(self, data): + response = super(MesssengerResultsSetPagination, self).get_paginated_response(data) + response.data['num_pages'] = self.page.paginator.num_pages + response.data['count']: self.page.paginator.count + return response + + +class UserSearchView(viewsets.ReadOnlyModelViewSet): + """ + Search user's username containing given query string. + + GET /messenger/api/v0/user/?search=name + Return list of users + [ + { + "username": "username1" + }, + ... + ] + """ + serializer_class = UserSerializer + authentication_classes = (SessionAuthentication,) + permission_classes = (permissions.IsAuthenticated,) + + def get_queryset(self): + query = self.request.GET.get('search', '') + if query: + return User.objects.filter( + username__icontains=query + ).exclude(username=self.request.user.username) + raise NotFound(_('Search query param is required.')) + + +class InboxView(viewsets.ModelViewSet): + """ + Returns list of all inbox messages of request user + Get /messenger/api/v0/inbox/ + { + "count": 2, + "next": null, + "previous": null, + "num_pages": 1 + "results": [ + { + "id": 37, + "with_user": "staff", + "last_message": "hello, how are you d...", + "unread_count": 0, + "with_user_img": "profile_image_url" + }, + ... + ], + } + + Retrieve single inbox message object + Get /messenger/api/v0/inbox/pk + { + "id": 37, + "with_user": "staff", + "last_message": "hello, how are you d...", + "unread_count": 0, + "with_user_img": "profile_image_url" + } + ``` + + Update inbox message object + PATCH /messenger/api/v0/inbox/pk + { + "unread_count": 2, + } + ``` + + Note: + - user can view and update only his inbox resources + + """ + authentication_classes = (SessionAuthentication,) + permission_classes = (permissions.IsAuthenticated,) + serializer_class = InboxSerializer + pagination_class = MesssengerResultsSetPagination + + def get_queryset(self): + return Inbox.user_inbox.find_all(self.request.user) + + +class ConversationView(viewsets.ModelViewSet): + """ + Return conversation between two users -> All messages sent or received between request.user and + user with given username. + GET /conversation/?with_user=username2 + [ + { + "id": 2093, + "sender": "honor", + "receiver": "edx", + "message": "what's the progress on task1 ?", + "created": "08/31/21-01:09 PM", + "sender_img": "sender_profile_image url" + }, + ... + ] + """ + authentication_classes = (SessionAuthentication,) + permission_classes = (permissions.IsAuthenticated, ) + serializer_class = MessageSerializer + pagination_class = MesssengerResultsSetPagination + + def get_queryset(self): + with_user = self.request.GET.get('with_user', '') + if not with_user: + raise NotFound(_('with_user param is required.')) + + try: + with_user = User.objects.get(username=with_user) + return Message.chat.history(self.request.user, with_user) + except User.DoesNotExist: + raise NotFound(_('User with username: {} does not exist.'.format(with_user))) + + +class MessageCreateView(generics.CreateAPIView): + """ + Create Single Message - + POST /messenger/api/v0/message/ + { + "receiver": "receiver_username", + "message": "sample message text" + } + Return newly created message + { + "id": 2092, + "sender": "sender_username", + "receiver": "receiver_username", + "message": "sample message text", + "created": "08/31/21-01:08 PM", + "sender_img": "sender_profile_image_url" + } + + """ + authentication_classes = (SessionAuthentication,) + permission_classes = (permissions.IsAuthenticated,) + serializer_class = MessageSerializer + + +class BulkMessageView(viewsets.ViewSet): + """ + Create Bulk messages from request.user + + POST /messenger/api/v0/bulk_message/ + POST DATA: { + "receivers": [username1, username2] + "message": "message sample text" + } + """ + serializer_class = BulkMessageSerializer + permission_classes = (permissions.IsAuthenticated,) + + def bulk_message(self, *ags, **kwargs): + serializer = BulkMessageSerializer(data=self.request.data) + if serializer.is_valid(raise_exception=True): + created_msgs = serializer.bulk_create(request=self.request) + return Response( + data=InboxSerializer( + Inbox.objects.filter(last_message__in=created_msgs), + context={'request': self.request}, + many=True + ).data, + status=status.HTTP_200_OK + ) diff --git a/openedx/features/wikimedia_features/messenger/apps.py b/openedx/features/wikimedia_features/messenger/apps.py new file mode 100644 index 000000000000..23f870cbd6cf --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/apps.py @@ -0,0 +1,29 @@ +""" +Messenger App Config +""" +from django.apps import AppConfig +from edx_django_utils.plugins import PluginURLs, PluginSettings +from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType + + +class MessengerConfig(AppConfig): + name = 'openedx.features.wikimedia_features.messenger' + + plugin_app = { + PluginURLs.CONFIG: { + ProjectType.LMS: { + PluginURLs.NAMESPACE: 'messenger', + PluginURLs.REGEX: '^messenger/', + PluginURLs.RELATIVE_PATH: 'urls', + } + }, + PluginSettings.CONFIG: { + ProjectType.LMS: { + SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: 'settings.common'}, + SettingsType.TEST: {PluginSettings.RELATIVE_PATH: 'settings.test'}, + } + } + } + + def ready(self): + from . import signals # pylint: disable=unused-import diff --git a/openedx/features/wikimedia_features/messenger/migrations/0001_initial.py b/openedx/features/wikimedia_features/messenger/migrations/0001_initial.py new file mode 100644 index 000000000000..7318f458f0dd --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 2.2.24 on 2021-09-02 10:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.db.models.manager + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message', models.TextField()), + ('created', models.DateTimeField(auto_now_add=True)), + ('receiver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='receiver', to=settings.AUTH_USER_MODEL)), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sender', to=settings.AUTH_USER_MODEL)), + ], + managers=[ + ('chat', django.db.models.manager.Manager()), + ], + ), + migrations.CreateModel( + name='Inbox', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('unread_count', models.IntegerField(default=0)), + ('last_message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='last_message', to='messenger.Message')), + ], + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['sender', 'receiver'], name='messenger_m_sender__f783e3_idx'), + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['created'], name='messenger_m_created_29ed55_idx'), + ), + ] diff --git a/openedx/features/wikimedia_features/messenger/migrations/__init__.py b/openedx/features/wikimedia_features/messenger/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/wikimedia_features/messenger/models.py b/openedx/features/wikimedia_features/messenger/models.py new file mode 100644 index 000000000000..fe61ccfc00e9 --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/models.py @@ -0,0 +1,67 @@ +""" +Messenger Models +""" +from django.db.models import Q +from django.core.exceptions import ValidationError +from django.db import models +from django.contrib.auth.models import User + +APP_LABEL = 'messenger' + + +class MessageHistoryManager(models.Manager): + def history(self, user1, user2): + return self.get_queryset().filter( + (Q(sender=user1) & Q(receiver=user2)) | + (Q(sender=user2) & Q(receiver=user1)) + ).order_by('-created') + + +class Message(models.Model): + message = models.TextField() + created = models.DateTimeField(auto_now_add=True) + receiver = models.ForeignKey(User, on_delete=models.CASCADE, related_name='receiver') + sender = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sender') + chat = MessageHistoryManager() + objects = models.Manager() + + def __str__(self): + return "from: {}, to: {}, message: {}".format( + self.sender.username, + self.receiver.username, + self.message[:20] + ) + + def full_clean(self): + super().full_clean() + if self.sender == self.receiver: + raise ValidationError('Sender can not send message to himself.') + + def save(self, *args, **kwargs): + self.full_clean() + return super(Message, self).save(*args, **kwargs) + + class Meta: + app_label = APP_LABEL + indexes = [ + models.Index(fields=['sender', 'receiver']), + models.Index(fields=['created']), + ] + + +class UserInboxManager(models.Manager): + def find_all(self, user): + return super().get_queryset().filter( + Q(last_message__sender=user) | + Q(last_message__receiver=user) + ) + + +class Inbox(models.Model): + unread_count = models.IntegerField(default=0) + last_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='last_message') + objects = models.Manager() + user_inbox = UserInboxManager() + + class Meta: + app_label = APP_LABEL diff --git a/openedx/features/wikimedia_features/messenger/settings/__init__.py b/openedx/features/wikimedia_features/messenger/settings/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/wikimedia_features/messenger/settings/common.py b/openedx/features/wikimedia_features/messenger/settings/common.py new file mode 100644 index 000000000000..5a46a9b8b17a --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/settings/common.py @@ -0,0 +1,11 @@ + +"""Common settings for Messenger""" + + +def plugin_settings(settings): + """ + Common settings for Messenger + """ + settings.MAKO_TEMPLATE_DIRS_BASE.append( + settings.OPENEDX_ROOT / 'features' / 'wikimedia_features' / 'messenger' / 'templates', + ) diff --git a/openedx/features/wikimedia_features/messenger/signals.py b/openedx/features/wikimedia_features/messenger/signals.py new file mode 100644 index 000000000000..a46900746b2c --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/signals.py @@ -0,0 +1,32 @@ +from django.db.models.signals import post_save +from django.db.models import Q +from django.dispatch import receiver + + +from openedx.features.wikimedia_features.messenger.models import Inbox, Message + + +@receiver(post_save, sender=Message) +def create_default_group(sender, instance, created, **kwargs): + """ + On new message update last_message in inbox + """ + if not created: + return + + try: + inbox = Inbox.objects.get( + Q(last_message__sender=instance.sender, last_message__receiver=instance.receiver) | + Q(last_message__sender=instance.receiver, last_message__receiver=instance.sender) + ) + if inbox.last_message.sender == instance.sender: + inbox.unread_count += 1 + else: + inbox.unread_count = 1 + inbox.last_message = instance + inbox.save() + except Inbox.DoesNotExist: + Inbox.objects.create( + last_message=instance, + unread_count=1 + ) diff --git a/openedx/features/wikimedia_features/messenger/static/messenger/js/Messenger.js b/openedx/features/wikimedia_features/messenger/static/messenger/js/Messenger.js new file mode 100644 index 000000000000..c90c6c11bd2f --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/static/messenger/js/Messenger.js @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import MessengerContent from "./MessengerContent" + +export class Messenger { + constructor(context) { + ReactDOM.render(, document.getElementById("root")); + } +} diff --git a/openedx/features/wikimedia_features/messenger/static/messenger/js/MessengerContent.js b/openedx/features/wikimedia_features/messenger/static/messenger/js/MessengerContent.js new file mode 100644 index 000000000000..5ad5277ad74e --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/static/messenger/js/MessengerContent.js @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import { ToastsContainer, ToastsStore } from "react-toasts"; + +import useInboxList from './hooks/useInboxList'; +import useSelectedInbox from './hooks/useSelectedInbox'; +import useCreateUpdateMessages from './hooks/useCreateUpdateMessages' +import useScrollObserver from './hooks/useScrollObserver'; + +import Inbox from "./components/Inbox/index" +import Conversation from "./components/Conversation/index" +import NewMessageModal from './components/newMessageModal' + + +export default function MessengerContent({ context }) { + const [inboxPageNumber, setInboxPageNumber] = useState(1) + const [messagesPageNumber, setMessagesPageNumber] = useState(1) + + const { + selectedInboxUser, + setSelectedInboxUser, + selectedInboxMessages, + setSelectedInboxMessages, + messagesLoading, + messagesHasMore + } = useSelectedInbox(messagesPageNumber, setMessagesPageNumber, context) + + const { + inboxList, + setInboxList, + inboxLoading, + inboxHasMore + } = useInboxList(inboxPageNumber, setSelectedInboxUser, context) + + const { + updateLastMessage, + createGroupMessages, + createMessage, + } = useCreateUpdateMessages(inboxList, setInboxList, selectedInboxUser, setSelectedInboxMessages, context) + + + const { + lastInboxRef, + lastMessageRef + } = useScrollObserver( + setInboxPageNumber, inboxLoading, inboxHasMore, + setMessagesPageNumber, messagesLoading, messagesHasMore + ) + + return ( +
+
+ + +
+ + +
+ ) +} diff --git a/openedx/features/wikimedia_features/messenger/static/messenger/js/client/index.js b/openedx/features/wikimedia_features/messenger/static/messenger/js/client/index.js new file mode 100644 index 000000000000..8ea00ab49089 --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/static/messenger/js/client/index.js @@ -0,0 +1,26 @@ +import axios from 'axios'; +import _ from 'lodash'; + +export default class HttpClient { + constructor(defaultOptions = {}) { + this._defaultOptions = defaultOptions; + } + _getOptions(options) { + return _.merge(options, this._defaultOptions); + } + get(url, options = {}) { + return axios.get(url, this._getOptions(options)); + } + post(url, data, options = {}) { + return axios.post(url, data, this._getOptions(options)); + } + put(url, data, options = {}) { + return axios.put(url, data, this._getOptions(options)); + } + patch(url, data, options = {}) { + return axios.patch(url, data, this._getOptions(options)); + } + delete(url, options = {}) { + return axios.delete(url, this._getOptions(options)); + } +} diff --git a/openedx/features/wikimedia_features/messenger/static/messenger/js/components/Conversation/index.js b/openedx/features/wikimedia_features/messenger/static/messenger/js/components/Conversation/index.js new file mode 100644 index 000000000000..25daab341e25 --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/static/messenger/js/components/Conversation/index.js @@ -0,0 +1,45 @@ +import React, { useState } from 'react' + +export default function Conversation({ + selectedInboxMessages, + createMessage, + messagesLoading, + updateLastMessage, + lastMessageRef, + selectedInboxUser +}) { + + const [message, setMessage] = useState("") + + const handleSendMessageBtnClick = () => { + createMessage(message, setMessage, updateLastMessage); + } + + return ( +
+
{selectedInboxUser}
+
+ {selectedInboxMessages && selectedInboxMessages.map( + (message, index) => { + let setRef = (selectedInboxMessages.length === index + 1 ) ? true : false; + return ( +
+ {message.sender} +
+ {message.sender} + {message.created} +

{message.message}

+
+
+ ) + } + )} +
{messagesLoading && "Loading conversation.."}
+
+
+ + +
+
+ ) +} diff --git a/openedx/features/wikimedia_features/messenger/static/messenger/js/components/Inbox/index.js b/openedx/features/wikimedia_features/messenger/static/messenger/js/components/Inbox/index.js new file mode 100644 index 000000000000..ad9907292c60 --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/static/messenger/js/components/Inbox/index.js @@ -0,0 +1,46 @@ +import React from 'react'; + +export default function Inbox({ + inboxList, lastInboxRef, inboxLoading, setSelectedInboxUser, + selectedInboxUser, +}) { + const handleInboxClick = (event) => { + setSelectedInboxUser(event.currentTarget.dataset.user) + } + + return ( +
+
+ +
+ +
    + { + (!inboxList) ? No conversation found!: inboxList.map( + (inbox, index) => { + let setRef = (inboxList.length === index + 1 ) ? true : false; + let name = (selectedInboxUser === inbox.with_user) ? "inbox-message active" : "inbox-message"; + return ( +
  • handleInboxClick(e)}> + {inbox.with_user} +
    +
    + {inbox.with_user} +
    +
    + {inbox.last_message.length > 30 ? `${inbox.last_message.substring(0, 30)}...` : inbox.last_message} +
    +
    + {inbox.unread_count ? inbox.unread_count : ""} +
  • + ) + } + ) + } +
    {inboxLoading && "Loading List..."}
    +
+
+ ) +} diff --git a/openedx/features/wikimedia_features/messenger/static/messenger/js/components/newMessageModal.js b/openedx/features/wikimedia_features/messenger/static/messenger/js/components/newMessageModal.js new file mode 100644 index 000000000000..5d02c2fb25a5 --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/static/messenger/js/components/newMessageModal.js @@ -0,0 +1,65 @@ +import React, {useState} from 'react' +import Multiselect from 'multiselect-react-dropdown'; +import useUserSearch from '../hooks/useUserSearch' + + +export default function NewMessageModal({createGroupMessages, context}) { + const [newMessageUsers, setNewMessageUsers] = useState([]) + const [groupNewMessage, setGroupNewMessage ] = useState("") + const [newMessageSelectedUsers, setNewMessageSelectedUsers] = useState([]) + const {fetchUsers} = useUserSearch(context) + + const handleSearch = (query) => { + fetchUsers(query, setNewMessageUsers) + } + + const handleNewMessageBtnClick = (event) => { + event.preventDefault(); + createGroupMessages(groupNewMessage, newMessageSelectedUsers); + } + + return ( +
+ +
+ ); +} diff --git a/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useClient.js b/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useClient.js new file mode 100644 index 000000000000..477e7c228ba1 --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useClient.js @@ -0,0 +1,12 @@ +import HttpClient from "../client"; +import Cookies from "js-cookie"; + +export default function useClient() { + const client = new HttpClient({ + headers: { + "X-CSRFToken": Cookies.get("csrftoken"), + }, + }); + + return { client } +} \ No newline at end of file diff --git a/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useCreateUpdateMessages.js b/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useCreateUpdateMessages.js new file mode 100644 index 000000000000..42bb24b436ff --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useCreateUpdateMessages.js @@ -0,0 +1,118 @@ +import { useEffect } from 'react' +import { ToastsStore } from "react-toasts"; +import useClient from "./useClient" + + +export default function useCreateUpdateMessages( + inboxList, setInboxList, selectedInboxUser, setSelectedInboxMessages, context) { + const { client } = useClient() + let currentInbox = {} + + const createMessage = async(message, setMessage, updateLastMessage) => { + try { + const createdMessage = (await client.post(context.MESSAGE_URL, { + receiver: selectedInboxUser, + message: message + })).data + if (createdMessage) { + setSelectedInboxMessages((prevMsgs) => [createdMessage, ...prevMsgs]); + setMessage("") + updateLastMessage(message) + } + ToastsStore.success("Message has been sent.") + } catch (e) { + console.error(e); + ToastsStore.error("Unable to send your message, please try again!") + } + } + + const createGroupMessages = async(message, users) => { + try { + const UpdatedInbox = (await client.post(context.BULK_MESSAGE_URL, { + receivers: users.map((user) => user.id), + message: message + })).data + + updateInboxList(UpdatedInbox); + updateOpenedConversation(message, users); + ToastsStore.success("Message has been sent.") + } catch (e) { + console.error(e); + ToastsStore.error("Unable to send your message, please try again!") + } + } + + const updateInboxList = (UpdatedInbox ) => { + let newlyCreatedInbox = [] + let newList = inboxList.map((inbox) => inbox) + UpdatedInbox.forEach((newInbox) => { + let existingInbox = inboxList.find((inbox) => inbox.id === newInbox.id) + if (existingInbox) { + newList = newList.map((inbox) => { + return inbox.id == newInbox.id ? newInbox : inbox; + }) + } else { + newlyCreatedInbox.push(newInbox) + } + }) + + if (newlyCreatedInbox) { + newList = [...newlyCreatedInbox, ...newList] + } + setInboxList(newList); + } + + const updateOpenedConversation = (message, users) => { + let isConversationOpened = users.some((user) => user.username == selectedInboxUser) + if (isConversationOpened) { + setSelectedInboxMessages((prevMsgs) => { + return [{ + sender: context.LOGIN_USER, + sender_img: context.LOGIN_USER_IMG, + created: "now", + message + }, ...prevMsgs, ] + }) + } + } + + const updateUnreadCount = async(inboxId) => { + try { + let updatedInbox = (await client.patch(`${context.INBOX_URL}${inboxId}/`, { + unread_count: 0 + })).data; + if (updatedInbox) { + setInboxList((previousList) => { + return previousList.map((inbox) => { + return (inbox.id === updatedInbox.id ? updatedInbox : inbox) + }) + }); + } + } catch (ex) { + ToastsStore.error('Failed: Failure while updating .'); + console.error(ex) + } + } + + const updateLastMessage = (message) => { + currentInbox = inboxList.find((inbox) => inbox.with_user == selectedInboxUser) + if (currentInbox) { + currentInbox.last_message = message.length > 30 ? `${message.substring(0, 30)}...`: message; + setInboxList((previousList) => { + return previousList.map((inbox) => { + return (inbox.id === currentInbox.id ? currentInbox : inbox) + }) + }); + } + } + + useEffect(() => { + currentInbox = inboxList.find((inbox) => inbox.with_user == selectedInboxUser) + if (currentInbox && currentInbox.unread_count) { + setTimeout(() => { updateUnreadCount(currentInbox.id) }, 3000); + + } + }, [selectedInboxUser]) + + return { updateLastMessage, createGroupMessages, createMessage, updateUnreadCount } +} diff --git a/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useInboxList.js b/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useInboxList.js new file mode 100644 index 000000000000..4ff77e53f751 --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useInboxList.js @@ -0,0 +1,35 @@ +import { useState, useEffect } from 'react'; +import useClient from "./useClient" +import { ToastsStore } from "react-toasts"; + +export default function useInboxList(inboxPageNumber, setSelectedInboxUser, context) { + const { client } = useClient() + const [inboxList, setInboxList] = useState([]); + const [inboxHasMore, setInboxHasMore] = useState(false); + const [inboxLoading, setInboxLoading] = useState(false); + + const fetchInboxList = async() => { + try { + setInboxLoading(true); + const inboxListData = (await client.get(`${context.INBOX_URL}?page=${inboxPageNumber}`)).data + if (inboxListData) { + setInboxList((previousList) => [...previousList, ...inboxListData.results]); + setInboxHasMore(inboxPageNumber < inboxListData.num_pages) + + if (inboxPageNumber == 1 && inboxListData.results.length) { + setSelectedInboxUser(inboxListData.results[0].with_user) + } + } + setInboxLoading(false); + } catch (e) { + console.error(e); + ToastsStore.error("Unable to fetch all conversations.") + } + } + + useEffect(() => { + fetchInboxList(); + }, [inboxPageNumber]) + + return { inboxList, setInboxList, inboxLoading, inboxHasMore } +}; diff --git a/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useScrollObserver.js b/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useScrollObserver.js new file mode 100644 index 000000000000..8188985d80a6 --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useScrollObserver.js @@ -0,0 +1,38 @@ +import { useRef, useCallback } from 'react' + +export default function useScrollObserver( + setInboxPageNumber, inboxLoading, inboxHasMore, + setMessagesPageNumber, messagesLoading, messagesHasMore +) { + + const messageObserver = useRef() + const InboxListObserver = useRef() + + const lastMessageRef = useCallback( + (node) => { + if (messagesLoading) return + if (messageObserver.current) messageObserver.current.disconnect(); + messageObserver.current = new IntersectionObserver(entries => { + if (entries[0].isIntersecting && messagesHasMore) { + setMessagesPageNumber(prevPageNumber => prevPageNumber + 1) + } + }) + if (node) messageObserver.current.observe(node) + }, [messagesLoading, messagesHasMore] + ) + + const lastInboxRef = useCallback( + (node) => { + if (inboxLoading) return + if (InboxListObserver.current) InboxListObserver.current.disconnect(); + InboxListObserver.current = new IntersectionObserver(entries => { + if (entries[0].isIntersecting && inboxHasMore) { + setInboxPageNumber(prevPageNumber => prevPageNumber + 1) + } + }) + if (node) InboxListObserver.current.observe(node) + }, [inboxLoading, inboxHasMore] + ) + + return { lastMessageRef, lastInboxRef } +} diff --git a/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useSelectedInbox.js b/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useSelectedInbox.js new file mode 100644 index 000000000000..19d5fcb0f8fd --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useSelectedInbox.js @@ -0,0 +1,54 @@ +import { useState, useEffect } from 'react'; +import useClient from "./useClient" +import { ToastsStore } from "react-toasts"; + +export default function useSelectedInbox(messagesPageNumber, setMessagesPageNumber, context) { + const { client } = useClient() + const [selectedInboxUser, setSelectedInboxUser] = useState(""); + const [selectedInboxMessages, setSelectedInboxMessages] = useState([]); + const [messagesLoading, setMessagesLoading] = useState(false); + const [messagesHasMore, setMessagesHasMore] = useState(false); + + const fetchSelectedInboxMessages = async(is_new_user, pageNumber) => { + try { + setMessagesLoading(true); + const inboxMessages = ( + await client.get( + `${context.CONVERSATION_URL}?page=${pageNumber}&with_user=${selectedInboxUser}` + ) + ).data + if (inboxMessages) { + if (is_new_user) { + setSelectedInboxMessages(inboxMessages.results); + } else { + setSelectedInboxMessages((previousList) => [...previousList, ...inboxMessages.results]); + } + setMessagesHasMore(pageNumber < inboxMessages.num_pages) + } + setMessagesLoading(false); + } catch (e) { + console.error(e); + ToastsStore.error(`Unable to load conversation of user: ${selectedInboxUser}.`) + } + } + + useEffect(() => { + if (selectedInboxUser) { + setMessagesPageNumber(1) + fetchSelectedInboxMessages(true, 1); + } + }, [selectedInboxUser]) + + useEffect(() => { + if (selectedInboxUser && messagesPageNumber > 1) fetchSelectedInboxMessages(false, messagesPageNumber); + }, [messagesPageNumber]) + + return { + selectedInboxUser, + setSelectedInboxUser, + selectedInboxMessages, + setSelectedInboxMessages, + messagesLoading, + messagesHasMore + } +}; diff --git a/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useUserSearch.js b/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useUserSearch.js new file mode 100644 index 000000000000..672b24b14a65 --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/static/messenger/js/hooks/useUserSearch.js @@ -0,0 +1,26 @@ +import { ToastsStore } from "react-toasts"; +import useClient from "./useClient" + +export default function useUserSearch(context) { + const { client } = useClient() + + const fetchUsers = async(query, setNewMessageUsers) => { + try { + if (query) { + let users = (await client.get(`${context.USER_SEARCH_URL}?search=${query}`)).data; + if (users) { + setNewMessageUsers(users.results.map((user)=>{ + return { + id: user.username, + username: user.username + } + })); + } + } + } catch (ex) { + ToastsStore.error('Failed to fetch users.'); + console.error(ex) + } + } + return { fetchUsers } +} diff --git a/openedx/features/wikimedia_features/messenger/templates/messenger.html b/openedx/features/wikimedia_features/messenger/templates/messenger.html new file mode 100644 index 000000000000..ebf1496260fc --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/templates/messenger.html @@ -0,0 +1,52 @@ +<%! +from django.urls import reverse +from django.utils.translation import ugettext as _ + +from openedx.core.djangolib.js_utils import js_escaped_string, dump_js_escaped_json +%> + +<%inherit file="main.html"/> +<%namespace name="static" file="static_content.html"/> + +<%block name="title"> + + ${_("Messenger - {platform_name}").format(platform_name=platform_name)} + + + +<%block name="body"> +
+
+ <%static:webpack entry="Messenger"> + var context = { + "PLATFORM_NAME": "${platform_name | n, js_escaped_string}", + "LOGIN_USER": "${login_user_username | n, js_escaped_string}", + "LOGIN_USER_IMG": "${login_user_img | n, js_escaped_string}", + + + "MESSAGE_URL": "${reverse( + 'messenger:messenger_api_v0:message_create' + ) | n, js_escaped_string}", + + + "BULK_MESSAGE_URL": "${reverse( + 'messenger:messenger_api_v0:bulk_message' + ) | n, js_escaped_string}", + + + "INBOX_URL": "${reverse( + 'messenger:messenger_api_v0:user_inbox_list' + ) | n, js_escaped_string}", + + "CONVERSATION_URL": "${reverse( + 'messenger:messenger_api_v0:conversation_list' + ) | n, js_escaped_string}", + + "USER_SEARCH_URL": "${reverse( + 'messenger:messenger_api_v0:user_search' + ) | n, js_escaped_string}", + } + + new Messenger(context); + + diff --git a/openedx/features/wikimedia_features/messenger/urls.py b/openedx/features/wikimedia_features/messenger/urls.py new file mode 100644 index 000000000000..86cae34b6bf2 --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/urls.py @@ -0,0 +1,19 @@ +""" +Urls for Messenger +""" +from django.conf.urls import include, url + +from openedx.features.wikimedia_features.messenger.views import render_messenger_home + + +urlpatterns = [ + url( + r'^$', + render_messenger_home, + name='messenger_home' + ), + url( + r'^api/v0/', + include('openedx.features.wikimedia_features.messenger.api.v0.urls', namespace='messenger_api_v0') + ), +] diff --git a/openedx/features/wikimedia_features/messenger/views.py b/openedx/features/wikimedia_features/messenger/views.py new file mode 100644 index 000000000000..925783463d1e --- /dev/null +++ b/openedx/features/wikimedia_features/messenger/views.py @@ -0,0 +1,16 @@ +""" +Views for Messenger +""" +from django.contrib.auth.decorators import login_required + +from common.djangoapps.edxmako.shortcuts import render_to_response +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user + + +@login_required +def render_messenger_home(request): + return render_to_response('messenger.html', { + 'uses_bootstrap': True, + 'login_user_username': request.user.username, + 'login_user_img': get_profile_image_urls_for_user(request.user, request).get("medium") + }) diff --git a/package-lock.json b/package-lock.json index cbd2c13e7524..d53f64beef8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5194,9 +5194,9 @@ "integrity": "sha512-vkHTluRCoq9FcsrldC0ulQHiyBYgVJB2CX53I8r0nTC6KnEij7Of0jpBspjt3/CuNb6fyoj3aOh9J2HgQUM0og==" }, "follow-redirects": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.1.tgz", - "integrity": "sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg==" + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz", + "integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==" }, "font-awesome": { "version": "4.7.0", @@ -8627,9 +8627,9 @@ } }, "lodash": { - "version": "4.17.5", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.5.tgz", - "integrity": "sha512-svL3uiZf1RwhH+cWrfZn3A4+U58wbP0tGVTLQPbjplZxZ8ROD9VLuNgsRniTlLe7OlSqR79RUehXgpBW/s0IQw==" + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash-es": { "version": "4.17.6", @@ -9385,6 +9385,11 @@ "duplexer2": "0.0.2" } }, + "multiselect-react-dropdown": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/multiselect-react-dropdown/-/multiselect-react-dropdown-2.0.5.tgz", + "integrity": "sha512-KIkhycdCrzLSMxnI9l2Q77BMx+qF3RYQwegZ5H70g3tcSae6h6JvEnwNm4oQWRiP0EMjlbYFl0aqd6KJEJyo5A==" + }, "mute-stream": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz", @@ -11160,14 +11165,40 @@ "integrity": "sha1-eHTi04kR0nGeoncx0yRF2l7EOUw=" }, "react": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-16.1.0.tgz", - "integrity": "sha512-hvKYlKqde2JNnNiEzORvSA0J1L7uSZ43l+J89ZNoP4EXxQrVNH0CFj8vorfPou3w+1ou1BNMBir2VVsuXtETRA==", + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", + "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", "requires": { - "fbjs": "^0.8.16", "loose-envify": "^1.1.0", "object-assign": "^4.1.1", - "prop-types": "^15.6.0" + "prop-types": "^15.6.2" + }, + "dependencies": { + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + }, + "dependencies": { + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + } + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + } } }, "react-clientside-effect": { @@ -11179,14 +11210,50 @@ } }, "react-dom": { - "version": "16.1.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.1.2.tgz", - "integrity": "sha512-e2iKeys/pJcIzszO+9EYxnTus6JI3pftzhXGG8K9B9co2SljknimfuY1/VlAG9nDBipDAc8jkIuuhlz+EiGL+g==", + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", + "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==", "requires": { - "fbjs": "^0.8.16", "loose-envify": "^1.1.0", "object-assign": "^4.1.1", - "prop-types": "^15.6.0" + "prop-types": "^15.6.2", + "scheduler": "^0.19.1" + }, + "dependencies": { + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + }, + "dependencies": { + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + } + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "scheduler": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", + "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + } } }, "react-dropzone": { @@ -11500,6 +11567,14 @@ "react-is": "^16.4.0" } }, + "react-toasts": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/react-toasts/-/react-toasts-3.0.6.tgz", + "integrity": "sha512-SgtyyytTG6wcjI4rgv4OZmHHLiETYfiHfyz1qLv8Cxu6h9I3p+asUgY3L5DSEorOBSS4Dtv87xwXhi0KsCgLvQ==", + "requires": { + "watchable-stores": "^2.0.2" + } + }, "react-transition-group": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", @@ -15258,6 +15333,11 @@ } } }, + "watchable-stores": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/watchable-stores/-/watchable-stores-2.0.2.tgz", + "integrity": "sha512-xKzypK+hG6gaZFcmiEvdazbD9Z8+hIlr+frC7Ne4N4LI0zvuPaT5uPrhn2RMxhiwaLVw70rctes+rPUTvwhPNg==" + }, "watchpack": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.4.0.tgz", diff --git a/package.json b/package.json index a8572a1399eb..f7626905cc67 100644 --- a/package.json +++ b/package.json @@ -39,19 +39,22 @@ "jquery.scrollto": "2.1.2", "js-cookie": "2.2.0", "jwt-decode": "^2.2.0", + "lodash": "^4.17.21", "moment": "2.19.3", "moment-timezone": "0.5.14", + "multiselect-react-dropdown": "^2.0.5", "node-sass": "4.12.0", "picturefill": "3.0.2", "popper.js": "1.12.9", "prop-types": "15.6.0", "raw-loader": "0.5.1", - "react": "16.1.0", - "react-dom": "16.1.2", + "react": "^16.8.0", + "react-dom": "^16.8.2", "react-focus-lock": "^1.19.1", "react-redux": "5.0.7", "react-router-dom": "5.1.2", "react-slick": "0.16.0", + "react-toasts": "^3.0.6", "redux": "3.7.2", "redux-thunk": "2.2.0", "requirejs": "2.3.5", diff --git a/setup.py b/setup.py index c1a6dcf82f19..b793ac07b232 100644 --- a/setup.py +++ b/setup.py @@ -95,6 +95,8 @@ "program_enrollments = lms.djangoapps.program_enrollments.apps:ProgramEnrollmentsConfig", "courseware_api = openedx.core.djangoapps.courseware_api.apps:CoursewareAPIConfig", "admin_dashboard = openedx.features.wikimedia_features.admin_dashboard.apps:AdminDashboardConfig", + "messenger = openedx.features.wikimedia_features.messenger.apps:MessengerConfig", + ], "cms.djangoapp": [ "announcements = openedx.features.announcements.apps:AnnouncementsConfig", diff --git a/webpack.common.config.js b/webpack.common.config.js index c7d7a821185f..fba10013c23c 100644 --- a/webpack.common.config.js +++ b/webpack.common.config.js @@ -115,6 +115,9 @@ module.exports = Merge.smart({ LatestUpdate: './openedx/features/course_experience/static/course_experience/js/LatestUpdate.js', WelcomeMessage: './openedx/features/course_experience/static/course_experience/js/WelcomeMessage.js', + // Wikimedia + Messenger: './openedx/features/wikimedia_features/messenger/static/messenger/js/Messenger.js', + AnnouncementsView: './openedx/features/announcements/static/announcements/jsx/Announcements.jsx', CookiePolicyBanner: './common/static/js/src/CookiePolicyBanner.jsx', @@ -442,4 +445,3 @@ module.exports = Merge.smart({ } }, {web: xmoduleJS}, workerConfig()); - diff --git a/wm-theme b/wm-theme new file mode 120000 index 000000000000..7e4c0c9ff7c6 --- /dev/null +++ b/wm-theme @@ -0,0 +1 @@ +../../../src/wm-theme \ No newline at end of file