Add direct message

This commit is contained in:
cuom1999 2021-11-20 22:23:03 -06:00
parent 259cb95b43
commit 2f8ef1b524
20 changed files with 1066 additions and 195 deletions

View file

@ -0,0 +1,28 @@
# Generated by Django 2.2.17 on 2021-10-11 00:14
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('judge', '0116_auto_20211011_0645'),
('chat_box', '0004_auto_20200505_2336'),
]
operations = [
migrations.CreateModel(
name='Room',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_one', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_one', to='judge.Profile', verbose_name='user 1')),
('user_two', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_two', to='judge.Profile', verbose_name='user 2')),
],
),
migrations.AddField(
model_name='message',
name='room',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='chat_box.Room', verbose_name='room id'),
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 2.2.17 on 2021-11-12 05:27
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('judge', '0116_auto_20211011_0645'),
('chat_box', '0005_auto_20211011_0714'),
]
operations = [
migrations.CreateModel(
name='UserRoom',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('last_seen', models.DateTimeField(verbose_name='last seen')),
('room', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='chat_box.Room', verbose_name='room id')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='judge.Profile', verbose_name='user')),
],
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 2.2.17 on 2021-11-12 05:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('chat_box', '0006_userroom'),
]
operations = [
migrations.AlterField(
model_name='userroom',
name='last_seen',
field=models.DateTimeField(auto_now_add=True, verbose_name='last seen'),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 2.2.17 on 2021-11-18 10:26
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('judge', '0116_auto_20211011_0645'),
('chat_box', '0007_auto_20211112_1255'),
]
operations = [
migrations.CreateModel(
name='Ignore',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ignored_users', models.ManyToManyField(to='judge.Profile')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ignored_chat_users', to='judge.Profile', verbose_name='user')),
],
),
]

View file

@ -8,12 +8,23 @@ from judge.models.profile import Profile
__all__ = ['Message']
class Room(models.Model):
user_one = models.ForeignKey(Profile, related_name="user_one", verbose_name='user 1', on_delete=CASCADE)
user_two = models.ForeignKey(Profile, related_name="user_two", verbose_name='user 2', on_delete=CASCADE)
def contain(self, profile):
return self.user_one == profile or self.user_two == profile
def other_user(self, profile):
return self.user_one if profile == self.user_two else self.user_two
def users(self):
return [self.user_one, self.user_two]
class Message(models.Model):
author = models.ForeignKey(Profile, verbose_name=_('user'), on_delete=CASCADE)
time = models.DateTimeField(verbose_name=_('posted time'), auto_now_add=True)
body = models.TextField(verbose_name=_('body of comment'), max_length=8192)
hidden = models.BooleanField(verbose_name='is hidden', default=False)
room = models.ForeignKey(Room, verbose_name='room id', on_delete=CASCADE, default=None, null=True)
def save(self, *args, **kwargs):
new_message = self.id
@ -25,3 +36,46 @@ class Message(models.Model):
verbose_name = 'message'
verbose_name_plural = 'messages'
ordering = ('-time',)
class UserRoom(models.Model):
user = models.ForeignKey(Profile, verbose_name=_('user'), on_delete=CASCADE)
room = models.ForeignKey(Room, verbose_name='room id', on_delete=CASCADE, default=None, null=True)
last_seen = models.DateTimeField(verbose_name=_('last seen'), auto_now_add=True)
class Ignore(models.Model):
user = models.ForeignKey(Profile, related_name="ignored_chat_users", verbose_name=_('user'), on_delete=CASCADE)
ignored_users = models.ManyToManyField(Profile)
@classmethod
def is_ignored(self, current_user, new_friend):
try:
return current_user.ignored_chat_users.get().ignored_users \
.filter(id=new_friend.id).exists()
except:
return False
@classmethod
def get_ignored_users(self, user):
return self.objects.get(user=user).ignored_users.all()
@classmethod
def add_ignore(self, current_user, friend):
ignore, created = self.objects.get_or_create(
user = current_user
)
ignore.ignored_users.add(friend)
@classmethod
def remove_ignore(self, current_user, friend):
ignore, created = self.objects.get_or_create(
user = current_user
)
ignore.ignored_users.remove(friend)
@classmethod
def toggle_ignore(self, current_user, friend):
if (self.is_ignored(current_user, friend)):
self.remove_ignore(current_user, friend)
else:
self.add_ignore(current_user, friend)

18
chat_box/utils.py Normal file
View file

@ -0,0 +1,18 @@
from cryptography.fernet import Fernet
from django.conf import settings
secret_key = settings.CHAT_SECRET_KEY
fernet = Fernet(secret_key)
def encrypt_url(creator_id, other_id):
message = str(creator_id) + '_' + str(other_id)
return fernet.encrypt(message.encode()).decode()
def decrypt_url(message_encrypted):
try:
dec_message = fernet.decrypt(message_encrypted.encode()).decode()
creator_id, other_id = dec_message.split('_')
return int(creator_id), int(other_id)
except Exception as e:
return None, None

View file

@ -1,19 +1,25 @@
from django.utils.translation import gettext as _
from django.views.generic import ListView
from django.http import HttpResponse, JsonResponse, HttpResponseBadRequest
from django.http import HttpResponse, JsonResponse, HttpResponseBadRequest, HttpResponsePermanentRedirect, HttpResponseRedirect
from django.core.paginator import Paginator
from django.core.exceptions import PermissionDenied
from django.shortcuts import render
from django.forms.models import model_to_dict
from django.db.models import Case, BooleanField
from django.db.models import Case, BooleanField, When, Q, Subquery, OuterRef, Exists, Count, IntegerField
from django.db.models.functions import Coalesce
from django.utils import timezone
from django.contrib.auth.decorators import login_required
from django.urls import reverse
import datetime
from judge import event_poster as event
from judge.jinja2.gravatar import gravatar
from .models import Message, Profile
from judge.models import Friend
from chat_box.models import Message, Profile, Room, UserRoom, Ignore
from chat_box.utils import encrypt_url, decrypt_url
import json
@ -21,22 +27,46 @@ class ChatView(ListView):
context_object_name = 'message'
template_name = 'chat/chat.html'
title = _('Chat Box')
paginate_by = 50
messages = Message.objects.filter(hidden=False)
paginator = Paginator(messages, paginate_by)
def __init__(self):
super().__init__()
self.room_id = None
self.room = None
self.paginate_by = 50
self.messages = None
self.paginator = None
def get_queryset(self):
return self.messages
def get(self, request, *args, **kwargs):
request_room = kwargs['room_id']
page = request.GET.get('page')
if request_room:
try:
self.room = Room.objects.get(id=request_room)
if not can_access_room(request, self.room):
return HttpResponseBadRequest()
except Room.DoesNotExist:
return HttpResponseBadRequest()
else:
request_room = None
if request_room != self.room_id or not self.messages:
self.room_id = request_room
self.messages = Message.objects.filter(hidden=False, room=self.room_id)
self.paginator = Paginator(self.messages, self.paginate_by)
if page == None:
update_last_seen(request, **kwargs)
return super().get(request, *args, **kwargs)
cur_page = self.paginator.get_page(page)
return render(request, 'chat/message_list.html', {
'object_list': cur_page.object_list,
'num_pages': self.paginator.num_pages
})
def get_context_data(self, **kwargs):
@ -45,7 +75,22 @@ class ChatView(ListView):
context['title'] = self.title
context['last_msg'] = event.last()
context['status_sections'] = get_status_context(self.request)
context['today'] = timezone.now().strftime("%d-%m-%Y")
context['room'] = self.room_id
context['unread_count_lobby'] = get_unread_count(None, self.request.profile)
if self.room:
users_room = [self.room.user_one, self.room.user_two]
users_room.remove(self.request.profile)
context['other_user'] = users_room[0]
context['other_online'] = get_user_online_status(context['other_user'])
context['is_ignored'] = Ignore.is_ignored(self.request.profile, context['other_user'])
else:
context['online_count'] = get_online_count()
context['message_template'] = {
'author': self.request.profile,
'id': '$id',
'time': timezone.now(),
'body': '$body'
}
return context
@ -73,21 +118,46 @@ def delete_message(request):
@login_required
def post_message(request):
ret = {'msg': 'posted'}
if request.method != 'POST':
return HttpResponseBadRequest()
if request.method == 'GET':
return JsonResponse(ret)
room = None
if request.POST['room']:
room = Room.objects.get(id=request.POST['room'])
if not can_access_room(request, room) or request.profile.mute:
return HttpResponseBadRequest()
new_message = Message(author=request.profile,
body=request.POST['body'])
body=request.POST['body'],
room=room)
new_message.save()
event.post('chat', {
'type': 'new_message',
if not room:
event.post('chat_lobby', {
'type': 'lobby',
'author_id': request.profile.id,
'message': new_message.id,
'room': 'None',
'tmp_id': request.POST.get('tmp_id')
})
else:
for user in room.users():
event.post('chat_' + str(user.id), {
'type': 'private',
'author_id': request.profile.id,
'message': new_message.id,
'room': room.id,
'tmp_id': request.POST.get('tmp_id')
})
return JsonResponse(ret)
def can_access_room(request, room):
return not room or room.user_one == request.profile or room.user_two == request.profile
@login_required
def chat_message_ajax(request):
if request.method != 'GET':
@ -100,6 +170,9 @@ def chat_message_ajax(request):
try:
message = Message.objects.filter(hidden=False).get(id=message_id)
room = message.room
if room and not room.contain(request.profile):
return HttpResponse('Unauthorized', status=401)
except Message.DoesNotExist:
return HttpResponseBadRequest()
return render(request, 'chat/message.html', {
@ -107,53 +180,156 @@ def chat_message_ajax(request):
})
def get_user_online_status():
last_five_minutes = timezone.now()-timezone.timedelta(minutes=5)
return Profile.objects \
.filter(display_rank='user',
last_access__gte = last_five_minutes)\
.annotate(is_online=Case(default=True,output_field=BooleanField()))\
.order_by('-rating')
@login_required
def update_last_seen(request, **kwargs):
if 'room_id' in kwargs:
room_id = kwargs['room_id']
elif request.method == 'GET':
room_id = request.GET.get('room')
elif request.method == 'POST':
room_id = request.POST.get('room')
else:
return HttpResponseBadRequest()
try:
profile = request.profile
room = None
if room_id:
room = Room.objects.get(id=int(room_id))
except Room.DoesNotExist:
return HttpResponseBadRequest()
except Exception as e:
return HttpResponseBadRequest()
if room and not room.contain(profile):
return HttpResponseBadRequest()
user_room, _ = UserRoom.objects.get_or_create(user=profile, room=room)
user_room.last_seen = timezone.now()
user_room.save()
return JsonResponse({'msg': 'updated'})
def get_admin_online_status():
all_admin = Profile.objects.filter(display_rank='admin')
last_five_minutes = timezone.now()-timezone.timedelta(minutes=5)
def get_online_count():
last_two_minutes = timezone.now()-timezone.timedelta(minutes=2)
return Profile.objects.filter(last_access__gte=last_two_minutes).count()
def get_user_online_status(user):
time_diff = timezone.now() - user.last_access
is_online = time_diff <= timezone.timedelta(minutes=2)
return is_online
def user_online_status_ajax(request):
if request.method != 'GET':
return HttpResponseBadRequest()
user_id = request.GET.get('user')
if user_id:
try:
user_id = int(user_id)
user = Profile.objects.get(id=user_id)
except Exception as e:
return HttpResponseBadRequest()
is_online = get_user_online_status(user)
return render(request, 'chat/user_online_status.html', {
'other_user': user,
'other_online': is_online,
'is_ignored': Ignore.is_ignored(request.profile, user)
})
else:
return render(request, 'chat/user_online_status.html', {
'online_count': get_online_count(),
})
def get_online_status(request_user, queryset, rooms=None):
if not queryset:
return None
last_two_minutes = timezone.now()-timezone.timedelta(minutes=2)
ret = []
for admin in all_admin:
is_online = False
if (admin.last_access >= last_five_minutes):
is_online = True
ret.append({'user': admin, 'is_online': is_online})
if rooms:
unread_count = get_unread_count(rooms, request_user)
count = {}
for i in unread_count:
count[i['other_user']] = i['unread_count']
for user in queryset:
is_online = False
if (user.last_access >= last_two_minutes):
is_online = True
user_dict = {'user': user, 'is_online': is_online}
if rooms and user.id in count:
user_dict['unread_count'] = count[user.id]
user_dict['url'] = encrypt_url(request_user.id, user.id)
ret.append(user_dict)
return ret
def get_status_context(request):
friend_list = request.profile.get_friends()
all_user_status = get_user_online_status()
friend_status = []
user_status = []
for user in all_user_status:
if user.username in friend_list:
friend_status.append(user)
def get_status_context(request, include_ignored=False):
if include_ignored:
ignored_users = Profile.objects.none()
queryset = Profile.objects
else:
user_status.append(user)
ignored_users = Ignore.get_ignored_users(request.profile)
queryset = Profile.objects.exclude(id__in=ignored_users)
last_two_minutes = timezone.now()-timezone.timedelta(minutes=2)
recent_profile = Room.objects.filter(
Q(user_one=request.profile) | Q(user_two=request.profile)
).annotate(
last_msg_time=Subquery(
Message.objects.filter(room=OuterRef('pk')).values('time')[:1]
),
other_user=Case(
When(user_one=request.profile, then='user_two'),
default='user_one',
)
).filter(last_msg_time__isnull=False)\
.exclude(other_user__in=ignored_users)\
.order_by('-last_msg_time').values('other_user', 'id')[:20]
recent_profile_id = [str(i['other_user']) for i in recent_profile]
joined_id = ','.join(recent_profile_id)
recent_rooms = [int(i['id']) for i in recent_profile]
recent_list = None
if joined_id:
recent_list = Profile.objects.raw(
f'SELECT * from judge_profile where id in ({joined_id}) order by field(id,{joined_id})')
friend_list = Friend.get_friend_profiles(request.profile).exclude(id__in=recent_profile_id)\
.exclude(id__in=ignored_users)\
.order_by('-last_access')
admin_list = queryset.filter(display_rank='admin')\
.exclude(id__in=friend_list).exclude(id__in=recent_profile_id)
all_user_status = queryset\
.filter(display_rank='user',
last_access__gte = last_two_minutes)\
.annotate(is_online=Case(default=True,output_field=BooleanField()))\
.order_by('-rating').exclude(id__in=friend_list).exclude(id__in=admin_list)\
.exclude(id__in=recent_profile_id)[:30]
return [
{
'title': 'Admins',
'user_list': get_admin_online_status(),
'title': 'Recent',
'user_list': get_online_status(request.profile, recent_list, recent_rooms),
},
{
'title': 'Following',
'user_list': friend_status,
'user_list': get_online_status(request.profile, friend_list),
},
{
'title': 'Users',
'user_list': user_status,
'title': 'Admins',
'user_list': get_online_status(request.profile, admin_list),
},
{
'title': 'Other',
'user_list': get_online_status(request.profile, all_user_status),
},
]
@ -162,4 +338,80 @@ def get_status_context(request):
def online_status_ajax(request):
return render(request, 'chat/online_status.html', {
'status_sections': get_status_context(request),
'unread_count_lobby': get_unread_count(None, request.profile),
})
@login_required
def get_room(user_one, user_two):
if user_one.id > user_two.id:
user_one, user_two = user_two, user_one
room, created = Room.objects.get_or_create(user_one=user_one, user_two=user_two)
return room
@login_required
def get_or_create_room(request):
decrypted_other_id = request.GET.get('other')
request_id, other_id = decrypt_url(decrypted_other_id)
if not other_id or not request_id or request_id != request.profile.id:
return HttpResponseBadRequest()
try:
other_user = Profile.objects.get(id=int(other_id))
except Exception:
return HttpResponseBadRequest()
user = request.profile
if not other_user or not user:
return HttpResponseBadRequest()
# TODO: each user can only create <= 300 rooms
room = get_room(other_user, user)
return JsonResponse({'room': room.id, 'other_user_id': other_user.id})
def get_unread_count(rooms, user):
if rooms:
mess = Message.objects.filter(room=OuterRef('room'),
time__gte=OuterRef('last_seen'))\
.exclude(author=user)\
.order_by().values('room')\
.annotate(unread_count=Count('pk')).values('unread_count')
return UserRoom.objects\
.filter(user=user, room__in=rooms)\
.annotate(
unread_count=Coalesce(Subquery(mess, output_field=IntegerField()), 0),
other_user=Case(
When(room__user_one=user, then='room__user_two'),
default='room__user_one',
)
).filter(unread_count__gte=1).values('other_user', 'unread_count')
else: # lobby
mess = Message.objects.filter(room__isnull=True,
time__gte=OuterRef('last_seen'))\
.exclude(author=user)\
.annotate(unread_count=Count('pk')).values('unread_count')
return UserRoom.objects\
.filter(user=user, room__isnull=True)\
.annotate(
unread_count=Coalesce(Subquery(mess, output_field=IntegerField()), 0),
).values_list('unread_count', flat=True)[0]
@login_required
def toggle_ignore(request, **kwargs):
user_id = kwargs['user_id']
if not user_id:
return HttpResponseBadRequest()
try:
other_user = Profile.objects.get(id=user_id)
except:
return HttpResponseBadRequest()
Ignore.toggle_ignore(request.profile, other_user)
next_url = request.GET.get('next', '/')
return HttpResponseRedirect(next_url)

View file

@ -1,4 +1,4 @@
from chat_box.views import ChatView, delete_message, post_message, chat_message_ajax, online_status_ajax
from chat_box.views import *
from django.conf import settings
from django.conf.urls import include, url
@ -24,8 +24,8 @@ from judge.views import TitledTemplateView, about, api, blog, comment, contests,
from judge.views.problem_data import ProblemDataView, ProblemSubmissionDiff, \
problem_data_file, problem_init_view
from judge.views.register import ActivationView, RegistrationView
from judge.views.select2 import AssigneeSelect2View, CommentSelect2View, ContestSelect2View, \
ContestUserSearchSelect2View, OrganizationSelect2View, ProblemSelect2View, TicketUserSelect2View, \
from judge.views.select2 import AssigneeSelect2View, ChatUserSearchSelect2View, CommentSelect2View, \
ContestSelect2View, ContestUserSearchSelect2View, OrganizationSelect2View, ProblemSelect2View, TicketUserSelect2View, \
UserSearchSelect2View, UserSelect2View
admin.autodiscover()
@ -288,6 +288,7 @@ urlpatterns = [
url(r'^select2/', include([
url(r'^user_search$', UserSearchSelect2View.as_view(), name='user_search_select2_ajax'),
url(r'^user_search_chat$', ChatUserSearchSelect2View.as_view(), name='chat_user_search_select2_ajax'),
url(r'^contest_users/(?P<contest>\w+)$', ContestUserSearchSelect2View.as_view(),
name='contest_user_search_select2_ajax'),
url(r'^ticket_user$', TicketUserSelect2View.as_view(), name='ticket_user_select2_ajax'),
@ -373,13 +374,15 @@ urlpatterns = [
url(r'^custom_checker_sample/', about.custom_checker_sample, name='custom_checker_sample'),
url(r'^chat/', include([
url(r'^$',
login_required(ChatView.as_view()),
name='chat'),
url(r'^(?P<room_id>\d*)$', login_required(ChatView.as_view()), name='chat'),
url(r'^delete/$', delete_message, name='delete_chat_message'),
url(r'^post/$', post_message, name='post_chat_message'),
url(r'^ajax$', chat_message_ajax, name='chat_message_ajax'),
url(r'^online_status/ajax$', online_status_ajax, name='online_status_ajax')
url(r'^online_status/ajax$', online_status_ajax, name='online_status_ajax'),
url(r'^get_or_create_room$', get_or_create_room, name='get_or_create_room'),
url(r'^update_last_seen$', update_last_seen, name='update_last_seen'),
url(r'^online_status/user/ajax$', user_online_status_ajax, name='user_online_status_ajax'),
url(r'^toggle_ignore/(?P<user_id>\d+)$', toggle_ignore, name='toggle_ignore'),
])),
url(r'^notifications/',

View file

@ -0,0 +1,18 @@
# Generated by Django 2.2.17 on 2021-10-10 23:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('judge', '0115_auto_20210525_0222'),
]
operations = [
migrations.AlterField(
model_name='contest',
name='format_name',
field=models.CharField(choices=[('atcoder', 'AtCoder'), ('default', 'Default'), ('ecoo', 'ECOO'), ('icpc', 'ICPC'), ('ioi', 'IOI')], default='default', help_text='The contest format module to use.', max_length=32, verbose_name='contest format'),
),
]

View file

@ -257,5 +257,13 @@ class Friend(models.Model):
else:
self.make_friend(current_user, new_friend)
@classmethod
def get_friend_profiles(self, current_user):
try:
ret = self.objects.get(current_user=current_user).users.all()
except Friend.DoesNotExist:
ret = []
return ret
def __str__(self):
return str(self.current_user)

View file

@ -4,6 +4,8 @@ from django.shortcuts import get_object_or_404
from django.utils.encoding import smart_text
from django.views.generic.list import BaseListView
from chat_box.utils import encrypt_url
from judge.jinja2.gravatar import gravatar
from judge.models import Comment, Contest, Organization, Problem, Profile
@ -121,3 +123,35 @@ class AssigneeSelect2View(UserSearchSelect2View):
def get_queryset(self):
return Profile.objects.filter(assigned_tickets__isnull=False,
user__username__icontains=self.term).distinct()
class ChatUserSearchSelect2View(BaseListView):
paginate_by = 20
def get_queryset(self): # TODO: add block
return _get_user_queryset(self.term)
def get(self, request, *args, **kwargs):
self.request = request
self.kwargs = kwargs
self.term = kwargs.get('term', request.GET.get('term', ''))
self.gravatar_size = request.GET.get('gravatar_size', 128)
self.gravatar_default = request.GET.get('gravatar_default', None)
self.object_list = self.get_queryset().values_list('pk', 'user__username', 'user__email', 'display_rank')
context = self.get_context_data()
return JsonResponse({
'results': [
{
'text': username,
'id': encrypt_url(request.profile.id, pk),
'gravatar_url': gravatar(email, self.gravatar_size, self.gravatar_default),
'display_rank': display_rank,
} for pk, username, email, display_rank in context['object_list']],
'more': context['page_obj'].has_next(),
})
def get_name(self, obj):
return str(obj)

View file

@ -55,8 +55,7 @@
overflow-y: scroll;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
max-height: 85%;
height: auto;
height: 75%;
}
#chat-input {
@ -90,6 +89,88 @@
display: block !important;
}
}
#chat-input, #chat-log .content-message {
font-family: "Segoe UI", "Lucida Grande", Arial, sans-serif;
}
.info-pic {
height: 90%;
border-radius: 50%;
padding: 0.05em;
border: 0.1px solid #ccc;
margin-left: 3em;
margin-bottom: 1.5px;
}
.info-circle {
position: absolute;
cx: 86%;
cy: 80%;
r: 6px;
stroke: white;
stroke-width: 1;
}
.info-name {
margin-left: 10px;
font-size: 2em;
font-weight: bold !important;
}
.info-name a {
display: table-caption;
}
#chat-info {
border-bottom: 2px solid darkgray;
display: flex;
}
#refresh-button {
padding: 0;
margin-left: auto;
margin-right: 0.3em;
background: transparent;
border: none;
height: 1.5em;
width: 1.5em;
}
#refresh-button:hover {
background: lightgreen;
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
transition: 1.5s ease-in-out;
}
.status-pic {
height: 1.3em;
width: 1.3em;
border-radius: 0.3em;
}
.status-container {
position: relative;
display: inline-flex;
}
.status-circle {
position: absolute;
bottom: 0;
right: 0;
cx: 18px;
cy: 18px;
r: 4.5px;
stroke: white;
stroke-width: 1;
}
.status-row {
display: flex;
font-size: 15px;
padding: 0.2em 0.2em 0.2em 1em;
border-radius: 4px;
}
.status-row:hover {
background: lightgray;
cursor: pointer;
}
.status-list {
padding: 0;
}
.status-section-title {
cursor: pointer;
margin-top: 0.5em;
}
@media (max-width: 799px) {
#chat-area {
height: 500px;

View file

@ -523,3 +523,13 @@ details {
padding: 5px 10px;
border-radius: 4px;
}
.control-button {
background: lightgray;
color: black !important;
border: 0;
}
.control-button:hover {
background: gray;
}

View file

@ -8,24 +8,53 @@
<script type="text/javascript" src="{{ static('event.js') }}"></script>
<script src="https://unpkg.com/@popperjs/core@2"></script>
<script type="module" src="https://unpkg.com/emoji-picker-element@1"></script>
<script type="text/javascript">
let message_template = `
{% with message=message_template %}
{% include "chat/message.html" %}
{% endwith %}
`;
</script>
<script type="text/javascript">
window.currentPage = 1;
window.limit_time = 0;
window.limit_time = 24;
window.messages_per_page = 50;
window.room_id = "{{room if room else ''}}";
window.unread_message = 0;
window.other_user_id = "{{other_user.id if other_user else ''}}";
window.num_pages = {{paginator.num_pages}};
window.lock = false;
function load_page(page) {
$.get('?page=' + page)
function load_page(page, refresh_html=false) {
var param = {
'page': page,
}
$.get("{{ url('chat', '') }}" + window.room_id, param)
.fail(function() {
console.log("Fail to load page " + page);
})
.done(function(data) {
if (refresh_html) {
$('#chat-log').html('');
$('#chat-box').scrollTop($('#chat-box')[0].scrollHeight);
window.lock = true;
}
window.num_pages = parseInt($('<div>' + data + '</div>').find('#num_pages').html());
var time = refresh_html ? 0 : 500;
setTimeout(function() {
let container = $('#chat-box');
let lastMsgPos = scrollTopOfBottom(container)
let $chat_box = $('#chat-box');
let lastMsgPos = scrollTopOfBottom($chat_box)
$('#loader').hide();
if (refresh_html) {
$('#chat-log').append(data);
}
else {
$('#chat-log').prepend(data);
}
$('.body-block').slice(0, window.messages_per_page).each(function() {
resize_emoji($(this));
@ -34,8 +63,14 @@
register_time($('.time-with-rel'));
merge_authors();
container.scrollTop(scrollTopOfBottom(container) - lastMsgPos);
}, 500);
if (!refresh_html) {
$chat_box.scrollTop(scrollTopOfBottom($chat_box) - lastMsgPos);
}
else {
$('#chat-box').scrollTop($('#chat-box')[0].scrollHeight);
}
window.lock = false;
}, time);
})
}
@ -46,7 +81,7 @@
function scrollContainer(container, loader) {
container.scroll(function() {
if (container.scrollTop() == 0) {
if (currentPage < {{paginator.num_pages}}) {
if (currentPage < window.num_pages && !window.lock) {
currentPage++;
loader.show();
load_page(currentPage);
@ -57,42 +92,122 @@
window.load_dynamic_update = function (last_msg) {
return new EventReceiver(
"{{ EVENT_DAEMON_LOCATION }}", "{{ EVENT_DAEMON_POLL_LOCATION }}",
['chat'], last_msg, function (message) {
switch (message.type) {
case 'new_message':
add_new_message(message.message);
break;
['chat_lobby', 'chat_{{request.profile.id}}'], last_msg, function (message) {
var room = (message.type == 'lobby') ? '' : message.room;
if (message.author_id == {{request.profile.id}}) {
check_new_message(message.message, message.tmp_id, room);
}
else {
add_new_message(message.message, room);
}
}
);
}
function add_new_message(message) {
function refresh_status() {
$.get("{{url('online_status_ajax')}}")
.fail(function() {
console.log("Fail to get online status");
})
.done(function(data) {
if (data.status == 403) {
console.log("Fail to retrieve data");
}
else {
$('#chat-online-list').html(data).find('.toggle').each(function () {
register_toggle($(this));
});
register_click_space();
}
})
var data = {
'user': window.other_user_id,
};
$.get("{{url('user_online_status_ajax')}}", data)
.fail(function() {
console.log("Fail to get user online status");
})
.done(function(data) {
$('#chat-info').html(data);
register_time($('.time-with-rel'));
register_setting();
})
}
function add_message(data) {
var $data = $(data);
resize_emoji($data.find('.body-block'));
$('#chat-log').append($data);
$('#chat-box').scrollTop($('#chat-box')[0].scrollHeight);
register_time($('.time-with-rel'));
MathJax.Hub.Queue(["Typeset",MathJax.Hub]);
merge_authors();
}
function add_new_message(message, room) {
function callback(update) {
if (!document['hidden']) {
if (update) update_last_seen();
refresh_status();
}
else {
window.unread_message++;
document.title = "(" + window.unread_message + ") " + "{{ _('New message(s)') }}";
}
}
if (room == window.room_id) {
$.get({
url: "{{ url('chat_message_ajax') }}",
data: {
message: message,
},
success: function (data) {
var $data = $(data);
resize_emoji($data.find('.body-block'));
$('#chat-log').append($data);
$('#chat-box').scrollTop($('#chat-box')[0].scrollHeight);
MathJax.Hub.Queue(["Typeset",MathJax.Hub]);
register_time($('.time-with-rel'));
merge_authors();
add_message(data);
callback(true);
},
error: function (data) {
if (data.status === 403)
console.log('No right to see: ' + message);
else {
console.log('Could not load chat message:');
console.log(data.responseText);
}
console.log('Could not add new message');
}
});
}
else {
callback(false);
}
}
function check_new_message(message, tmp_id, room) {
if (room == "{{room}}") {
$.get({
url: "{{ url('chat_message_ajax') }}",
data: {
message: message,
},
success: function (data) {
var $body_block = $(data).find('.body-block');
if ($('#message-'+tmp_id).length) {
$('#message-'+tmp_id).replaceWith(data);
}
else {
$('#body-block-'+tmp_id).replaceWith($body_block);
}
resize_emoji($body_block);
MathJax.Hub.Queue(["Typeset",MathJax.Hub]);
register_time($('.time-with-rel'));
remove_unread_current_user();
},
error: function (data) {
console.log('Fail to check message');
var $body = $('#body-block-'+tmp_id + ' p');
$body.css('text-decoration', 'line-through');
$body.css('text-decoration-color', 'red');
}
});
}
}
function merge_authors() {
var time_limit = 5; // minutes
@ -119,25 +234,40 @@
});
}
function add_message_from_template(body, tmp_id) {
var html = message_template;
html = html.replaceAll('$body', body).replaceAll('$id', tmp_id);
var $html = $(html);
$html.find('.time-with-rel').attr('data-iso', (new Date()).toISOString());
add_message($html[0].outerHTML, false);
}
function submit_chat() {
{% if last_msg and not request.profile.mute %}
if ($("#chat-input").val().trim()) {
var body = $('#chat-input').val().trim();
body = body.split('\n').join('\n\n');
var message = {
body: body,
room: window.room_id,
tmp_id: Date.now(),
};
$('#chat-input').val('');
add_message_from_template(body, message.tmp_id);
$.post("{{ url('post_chat_message') }}", message)
.fail(function(res) {
console.log('Fail to send message');
})
.done(function(res, status) {
$('#empty_msg').hide();
$('#chat-input').focus();
})
}
{% endif %}
}
function resize_emoji(element) {
@ -165,6 +295,77 @@
}
}
function register_click_space() {
function callback() {
history.replaceState(null, '', "{{url('chat', '')}}" + window.room_id);
load_page(window.currentPage, true, refresh_status);
update_last_seen();
refresh_status();
$('#chat-input').focus();
}
$('.click_space').on('click', function(e) {
if ($(this).attr('id') == 'click_space_' + window.other_user_id) {
return;
}
var other_user = $(this).attr('value');
$.get("{{url('get_or_create_room')}}" + `?other=${other_user}`)
.done(function(data) {
window.currentPage = 1;
window.room_id = data.room;
window.other_user_id = data.other_user_id;
callback();
})
.fail(function() {
console.log('Fail to get_or_create_room');
})
});
$('#lobby_row').on('click', function(e) {
if (window.room_id) {
window.currentPage = 1;
window.room_id = '';
window.other_user_id = '';
callback();
}
});
}
function update_last_seen() {
var data = {
room: window.room_id
};
$.post("{{ url('update_last_seen') }}", data)
.fail(function(data) {
console.log('Fail to update last seen');
})
.done(function(data) {
})
}
function remove_unread_current_user() {
if (window.other_user_id) {
$("#unread-count-" + window.other_user_id).hide();
}
else {
$('#unread-count-lobby').hide();
}
}
function register_setting() {
$('#setting-button').on('click', function() {
$('#setting-content').toggle();
});
$('#setting-content li').on('click', function() {
$(this).children('a')[0].click();
})
$('#setting-content a').on('click', function() {
var href = $(this).attr('href');
href += '?next=' + window.location.pathname;
$(this).attr('href', href);
})
}
$(function() {
$('#loader').hide();
merge_authors();
@ -191,7 +392,7 @@
}
},
fail: function(data) {
alert('Fail to delete');
console.log('Fail to delete');
},
});
});
@ -201,6 +402,7 @@
resize_emoji($(this));
});
$("#chat-log").show();
$("#chat-log").change(function() {
$('#chat-log').scrollTop($('#chat-log')[0].scrollHeight);
});
@ -237,26 +439,9 @@
$('.chat-right-panel').show();
});
$('#refresh-button').on('click', function() {
$.get("{{url('online_status_ajax')}}")
.fail(function() {
console.log("Fail to get online status");
})
.done(function(data) {
if (data.status == 403) {
console.log("Fail to retrieve data");
}
else {
$('#chat-online-content').html(data).find('.toggle').each(function () {
register_toggle($(this));
});;
}
})
})
$('#refresh-button').on('click', refresh_status)
setInterval(function() {
$('#refresh-button').click();
}, 5 * 60 * 1000);
setInterval(refresh_status, 2 * 60 * 1000);
$('#chat-box').scrollTop($('#chat-box')[0].scrollHeight);
load_dynamic_update({{last_msg}});
@ -279,11 +464,67 @@
$chat.focus();
})
register_click_space();
document.addEventListener('keydown', function(e) {
if (e.keyCode === 27 && $('.tooltip').hasClass('shown')) {
toggleEmoji();
}
})
$('#search-handle').replaceWith($('<select>').attr({
id: 'search-handle',
name: 'other',
onchange: 'form.submit()'
}));
var in_user_redirect = false;
$('#search-handle').select2({
placeholder: '{{ _('Search by handle...') }}',
ajax: {
url: '{{ url('chat_user_search_select2_ajax') }}'
},
minimumInputLength: 1,
escapeMarkup: function (markup) {
return markup;
},
templateResult: function (data, container) {
return $('<span>')
.append($('<img>', {
'class': 'user-search-image', src: data.gravatar_url,
width: 24, height: 24
}))
.append($('<span>', {'class': data.display_rank + ' user-search-name'}).text(data.text))
.append($('<a>', {href: '/user/' + data.text, 'class': 'user-redirect'})
.append($('<i>', {'class': 'fa fa-mail-forward'}))
.mouseover(function () {
in_user_redirect = true;
}).mouseout(function () {
in_user_redirect = false;
}));
}
}).on('select2:selecting', function () {
return !in_user_redirect;
});
// https://stackoverflow.com/questions/42121565/detecting-class-change-without-setinterval
if (typeof(MutationObserver) !== undefined) {
var observer = new MutationObserver(function (event) {
if (!document['hidden'] && window.unread_message > 0) {
update_last_seen();
refresh_status();
window.unread_message = 0;
document.title = "{{_('Chat Box')}}";
}
})
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class'],
childList: false,
characterData: false
})
}
register_setting();
});
</script>
@ -314,13 +555,27 @@
</button>
</h3>
<div id="chat-online-content">
<div id="search-container">
<center>
<form id="search-form" name="form" action="{{ url('get_or_create_room') }}" method="get">
<input id="search-handle" type="text" name="search"
placeholder="{{ _('Search by handle...') }}">
</form>
</center>
</div>
<div id="chat-online-list">
{% include "chat/online_status.html" %}
</div>
</div>
</div>
<div id="chat-area" class="chat-left-panel" style="width:100%">
<div id="chat-info" style="height: 8%">
{% include 'chat/user_online_status.html' %}
</div>
<div id="chat-box">
<img src="http://opengraphicdesign.com/wp-content/uploads/2009/01/loader64.gif" id="loader">
<ul id="chat-log">
<img src="{{static('loading.gif')}}" id="loader">
<ul id="chat-log" style="display: none">
{% include 'chat/message_list.html' %}
</ul>
</div>

View file

@ -2,53 +2,7 @@
#content {
margin: -1em 1em 0 0;
}
#refresh-button {
padding: 0;
margin-left: auto;
margin-right: 0.3em;
background: transparent;
border: none;
height: 1.5em;
width: 1.5em;
}
#refresh-button:hover {
background: lightgreen;
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
transition: 1.5s ease-in-out;
}
.status-pic {
height: 1.3em;
width: 1.3em;
border-radius: 0.3em;
}
.status-container {
position: relative;
display: inline-flex;
}
.status-circle {
position: absolute;
bottom: 0;
right: 0;
cx: 18px;
cy: 18px;
r: 4.5px;
stroke: white;
stroke-width: 1;
}
.status-row {
display: flex;
margin-bottom: 0.5em;
font-size: 15px;
}
.status-list {
padding: 0;
padding-left: 1em;
}
.status-section-title {
cursor: pointer;
margin-top: 0.5em;
}
::-webkit-scrollbar {
width: 20px;
}
@ -143,14 +97,55 @@
.body-block:hover {
background: #eee;
}
#chat-input, #chat-log .content-message {
font-family: "Apple Color Emoji", "Segoe UI", "Lucida Grande", Arial, sans-serif;
.active-span {
margin-top: 1em;
margin-right: 1em;
color: #636363;
}
@media (min-width: 800px) {
.unread-count {
float: right;
color: white;
background-color: darkcyan;
border-radius: 2px;
padding: 0 0.5em;
}
#search-form {
float: inherit;
}
#search-container {
margin-bottom: 0.4em;
}
#setting {
position: relative;
}
#setting-content {
display: none;
position: absolute;
background-color: #f1f1f1;
min-width: 160px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
z-index: 1;
right: 0;
}
#setting-content li {
padding: 12px 16px;
text-decoration: none;
display: block;
color: black;
font-weight: bold;
}
#setting-content li:hover {
background-color: #ddd;
cursor: pointer;
}
#page-container {
position:fixed;
overflow:hidden;
}
@media (min-width: 800px) {
}
@media (max-width: 799px) {
html, body {
@ -160,5 +155,11 @@
#mobile ul {
width: 100%;
}
.info-pic {
margin-left: 0.5em;
}
.active-span {
display: none;
}
}
</style>

View file

@ -6,7 +6,7 @@
<div class="user-time">
<span class="username {{ message.author.css_class }}">
<a href="{{ url('user_page', message.author.user.username) }}">
{{message.author}}
{{ message.author }}
</a>
</span>
<span class="time">
@ -14,7 +14,7 @@
</span>
</div>
<span class="content-message">
<div class="body-block" title="{{ message.time|date('g:i a') }}">
<div class="body-block" id="body-block-{{ message.id }}" title="{{ message.time|date('g:i a') }}">
{% if request.user.is_staff %}
<a class="chatbtn_remove_mess" value="{{message.id}}" style="color:red; cursor: pointer;">
{{_('Delete')}}

View file

@ -1,7 +1,11 @@
{% if object_list %}
<div style="display: none" id="num_pages">{{num_pages}}</div>
{% for message in object_list | reverse%}
{% include "chat/message.html" %}
{% endfor %}
{% else %}
<center id="empty_msg">{{_('You are connect now. Say something to start the conversation.')}}</center>
{% endif %}
{% if REQUIRE_JAX %}
{% include "mathjax-load.html" %}
{% endif %}

View file

@ -1,3 +1,14 @@
<li class="status-row" id="lobby_row">
<div class="status-container">
<img src="{{ static('icons/logo.png') }}" style="height:1.3em">
</div>
<span style="padding-left:0.5em">
<b>{{_('Lobby')}}</b>
</span>
<span class="spacer">
<span class="unread-count" id="unread-count-lobby">{{unread_count_lobby if unread_count_lobby}}</span>
</span>
</li>
{% for section in status_sections %}
{% if section.user_list %}
<div class="status-section-title toggle open">
@ -8,7 +19,7 @@
</div>
<ul class="status-list toggled">
{% for user in section.user_list %}
<li style="padding-left: 0.1em" class="status-row">
<li class="status-row">
<div class="status-container">
<img src="{{ gravatar(user.user, 135) }}" class="status-pic">
<svg style="position:absolute;" height="32" width="32">
@ -19,6 +30,9 @@
<span style="padding-left:0.3em" class="username {{ user.user.css_class }}">
{{ link_user(user.user) }}
</span>
<span class="click_space spacer" value="{{user.url}}" id="click_space_{{user.user.id}}">
<span class="unread-count" id="unread-count-{{user.user.id}}">{{user.unread_count if user.unread_count}}</span>
</span>
</li>
{% endfor %}
</ul>

View file

@ -0,0 +1,35 @@
{% if other_user %}
<div class="status-container" style="height: 100%">
<img src="{{ gravatar(other_user.user, 135) }}" class="info-pic">
<svg style="position:absolute; height:100%; width: 110%">
<circle class="info-circle"
fill="{{'green' if other_online else 'red'}}"/>
</svg>
</div>
{% endif %}
<span class="info-name username">
{% if other_user %}
<a href="{{url('user_page', other_user)}}">{{other_user.user.username}}</a>
{% else%}
<a href="#" style="margin-left: 3em">{{ _('Lobby') }}</a>
{% endif %}
</span>
<span class="spacer"></span>
{% if other_user and not other_online %}
<span class="active-span">{{ relative_time(other_user.last_access, abs=_('Last online on {time}'), rel=_('Online {time}'), format=_('g:i a d/m/Y')) }}</span>
{% endif %}
{% if other_user %}
<span style="margin-right: 0.3em" id="setting">
<button class="control-button" style="height:100%;" id="setting-button">
<i class="fa fa-ellipsis-h"></i>
</button>
<div id="setting-content">
<li>
<a href="{{url('toggle_ignore', other_user.id)}}" style="color: {{'green' if is_ignored else 'red'}}">{{_('Unignore') if is_ignored else _('Ignore')}}</a>
</li>
</div>
</span>
{% else %}
<span class="active-span">{{online_count}} {{_('users are online')}}</span>
{% endif %}

View file

@ -2,15 +2,6 @@
{% block media %}
<style>
#control-button {
margin-left: 0.8em;
background: lightgray;
color: black !important;
border: 0;
}
#control-button:hover {
background: gray;
}
{% if request.user.is_authenticated and is_member %}
#control-panel {
display: none;
@ -63,7 +54,7 @@
$('.blog-content').hide();
$('.blog-sidebar').show();
});
$('#control-button').click(function(e) {
$('.control-button').click(function(e) {
e.preventDefault();
$('#control-panel').toggle("fast");
})
@ -92,7 +83,7 @@
class="unselectable button">{{ _('Request membership') }}</a>
{% endif %}
{% endif %}
<button id="control-button"><i class="fa fa-ellipsis-h"></i></button>
<button class="control-button"><i class="fa fa-ellipsis-h"></i></button>
</div>
</div>
{% endblock %}