├── .nvmrc ├── .yvmrc ├── tests ├── __init__.py ├── apps.py ├── test_models.py ├── urls.py └── settings.py ├── example ├── example │ ├── __init__.py │ ├── wsgi.py │ ├── urls.py │ └── settings.py ├── manage.py └── templates │ └── base.html ├── conversate ├── migrations │ ├── __init__.py │ ├── 0003_message_file.py │ ├── 0002_auto_20181120_0655.py │ └── 0001_initial.py ├── templatetags │ ├── __init__.py │ └── conversate.py ├── static │ └── conversate │ │ ├── images │ │ └── icons.png │ │ └── css │ │ └── styles.css ├── __init__.py ├── templates │ └── conversate │ │ ├── index.html │ │ ├── base.html │ │ ├── includes │ │ ├── room_messages.html │ │ ├── room_input.html │ │ └── room_sidebar.html │ │ └── room.html ├── admin.py ├── forms.py ├── utils.py ├── urls.py ├── settings.py ├── decorators.py ├── models.py └── views.py ├── MANIFEST.in ├── static_src ├── scripts │ ├── watch.sh │ ├── build.sh │ ├── watch-js.sh │ ├── watch-css.sh │ ├── build-js.sh │ └── build-css.sh ├── scss │ └── styles.scss └── js │ └── room.js ├── .gitignore ├── requirements.in ├── setup.py ├── tox.ini ├── package.json ├── .travis.yml ├── LICENSE ├── CHANGES ├── setup.cfg ├── requirements.txt └── README.rst /.nvmrc: -------------------------------------------------------------------------------- 1 | 12.18.3 2 | -------------------------------------------------------------------------------- /.yvmrc: -------------------------------------------------------------------------------- 1 | 1.22.5 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conversate/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conversate/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conversate/static/conversate/images/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiac/django-conversate/HEAD/conversate/static/conversate/images/icons.png -------------------------------------------------------------------------------- /tests/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ConversateConfig(AppConfig): 5 | name = "conversate" 6 | verbose_name = "Conversate" 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | recursive-include conversate/static * 3 | recursive-include conversate/templates * 4 | recursive-include tests * 5 | recursive-exclude tests *.pyc 6 | -------------------------------------------------------------------------------- /conversate/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django Conversate - Persistant chat for Django 3 | """ 4 | 5 | __version__ = "0.3.0" 6 | __license__ = "BSD" 7 | __author__ = "Richard Terry" 8 | __credits__ = [] 9 | -------------------------------------------------------------------------------- /static_src/scripts/watch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Watch all static assets for django-conversate 4 | 5 | # Terminate script on error and print each command 6 | set -ex 7 | 8 | npm-run-all --parallel watch-js watch-css 9 | -------------------------------------------------------------------------------- /static_src/scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Build all static assets for django-conversate 4 | 5 | # Terminate script on error and print each command 6 | set -ex 7 | 8 | npm run build-js 9 | npm run build-css 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dev 2 | *.pyc 3 | __pycache__ 4 | node_modules 5 | 6 | # Test 7 | .coverage* 8 | htmlcov 9 | test_media 10 | 11 | # Example 12 | docker-compose.dev.yml 13 | example/db.sqlite3 14 | 15 | # Build 16 | *.egg-info 17 | build 18 | dist 19 | docs/_build 20 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 8 | 9 | from django.core.management import execute_from_command_line 10 | 11 | execute_from_command_line(sys.argv) 12 | -------------------------------------------------------------------------------- /static_src/scripts/watch-js.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Watch js resources for django-conversate 4 | 5 | # Terminate script on error and print each command 6 | set -ex 7 | 8 | watchify \ 9 | -vd \ 10 | -p \ 11 | browserify ./static_src/js/room.js \ 12 | -o ./conversate/static/conversate/js/room.js \ 13 | -v \ 14 | -d \ 15 | -s room \ 16 | --poll 17 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from model_bakery import baker 2 | 3 | from conversate.models import Message, Room 4 | 5 | 6 | def test_message_render__emoji_markdown(admin_user): 7 | room = baker.make(Room, users=[admin_user]) 8 | msg = Message.objects.create(room=room, user=admin_user, content=":thumbsup: *em*") 9 | assert msg.render() == "

\U0001F44D em

\n" 10 | -------------------------------------------------------------------------------- /conversate/templates/conversate/index.html: -------------------------------------------------------------------------------- 1 | {% extends "conversate/base.html" %} 2 | 3 | {% block conversate_content %} 4 | {% if rooms %} 5 | {% for room in rooms %} 6 |

{{ room.title }}

7 | {% endfor %} 8 | {% else %} 9 |

There are no rooms available.

10 | {% endif %} 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | # 2 | # Developer requirements 3 | # 4 | 5 | # Dev tools 6 | black 7 | flake8 8 | isort 9 | pip-tools 10 | 11 | # Testing 12 | model_bakery 13 | pytest 14 | pytest-black 15 | pytest-cov 16 | pytest-django 17 | pytest-flake8 18 | pytest-isort 19 | tox 20 | 21 | # Docs 22 | sphinx 23 | sphinx-autobuild 24 | 25 | # Project (setup.cfg) 26 | django-yaa-settings 27 | commonmark 28 | emoji 29 | -------------------------------------------------------------------------------- /conversate/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from . import models 4 | 5 | 6 | class RoomUserAdmin(admin.TabularInline): 7 | model = models.RoomUser 8 | 9 | 10 | class RoomAdmin(admin.ModelAdmin): 11 | list_display = [ 12 | "title", 13 | "slug", 14 | ] 15 | inlines = [ 16 | RoomUserAdmin, 17 | ] 18 | 19 | 20 | admin.site.register(models.Room, RoomAdmin) 21 | -------------------------------------------------------------------------------- /static_src/scripts/watch-css.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Watch css resources for django-conversate 4 | 5 | # Terminate script on error and print each command 6 | set -ex 7 | 8 | # Make sure it's up to date right now 9 | npm run build-css 10 | 11 | # Now watch for changes 12 | node-sass \ 13 | --include-path node_modules \ 14 | ./static_src/scss/styles.scss \ 15 | -w ./conversate/static/conversate/css/styles.css 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | 4 | from setuptools import setup 5 | 6 | 7 | def find_version(*paths): 8 | path = Path(*paths) 9 | content = path.read_text() 10 | match = re.search(r"^__version__\s*=\s*['\"]([^'\"]*)['\"]", content, re.M) 11 | if match: 12 | return match.group(1) 13 | raise RuntimeError("Unable to find version string.") 14 | 15 | 16 | setup(version=find_version("conversate", "__init__.py")) 17 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | 15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /conversate/templates/conversate/base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load static %} 3 | 4 | {% block css %} 5 | 6 | {{ block.super }} 7 | {% endblock %} 8 | 9 | {% block js %} 10 | {{ block.super }} 11 | 12 | {% endblock %} 13 | 14 | {% block title %}{{ title }}{% endblock %} 15 | 16 | {% block content %} 17 |
18 | {% block conversate_content %} 19 | {% endblock %} 20 |
21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /conversate/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from . import models 4 | 5 | 6 | class MessageForm(forms.ModelForm): 7 | use_required_attribute = False 8 | 9 | class Meta: 10 | model = models.Message 11 | fields = ["content", "file"] 12 | widgets = { 13 | "content": forms.Textarea( 14 | attrs={ 15 | "placeholder": "Message", 16 | "rows": 1, 17 | }, 18 | ), 19 | } 20 | 21 | 22 | class SettingsForm(forms.ModelForm): 23 | class Meta: 24 | model = models.RoomUser 25 | fields = ["alert", "mail_alert"] 26 | -------------------------------------------------------------------------------- /static_src/scripts/build-js.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Build js resources for django-conversate 4 | 5 | # Terminate script on error and print each command 6 | set -ex 7 | 8 | # Arguments: 9 | # source 10 | # -g uglifyify Global transform 11 | # -v Verbose 12 | # -s room Standalone, available as window.room 13 | # -o output Output file 14 | browserify \ 15 | ./static_src/js/room.js \ 16 | -g uglifyify \ 17 | -v \ 18 | -s room \ 19 | -o ./conversate/static/conversate/js/room.js 20 | 21 | # Uglify the code 22 | uglifyjs \ 23 | ./conversate/static/conversate/js/room.js \ 24 | -o ./conversate/static/conversate/js/room.js 25 | -------------------------------------------------------------------------------- /static_src/scripts/build-css.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Build css resources for django-conversate 4 | 5 | # Terminate script on error and print each command 6 | set -ex 7 | 8 | # Compile all scss files into css files 9 | node-sass \ 10 | --include-path node_modules \ 11 | ./static_src/scss/ \ 12 | --output ./conversate/static/conversate/css/ \ 13 | --sourcemap \ 14 | --no-recursive 15 | 16 | # Uglify all CSS files 17 | find \ 18 | ./conversate/static/conversate/css/ \ 19 | -iname \"*.css\" \ 20 | -print \ 21 | -exec \ 22 | sh \ 23 | -c 'uglifycss \"$1\" > \"$1.tmp\" && rm \"$1\" && mv \"$1.tmp\" \"$1\"' \ 24 | -- {} \ 25 | \; 26 | -------------------------------------------------------------------------------- /conversate/migrations/0003_message_file.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.16 on 2020-09-12 21:24 2 | 3 | import django.core.files.storage 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("conversate", "0002_auto_20181120_0655"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="message", 16 | name="file", 17 | field=models.FileField( 18 | blank=True, 19 | null=True, 20 | storage=django.core.files.storage.FileSystemStorage( 21 | location="/home/radiac/work/sites/uzeweb.com/my/site/../media" 22 | ), 23 | upload_to="conversate", 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | """testapp URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.urls import include, path 17 | 18 | 19 | urlpatterns = [ 20 | path("chat/", include("conversate.urls", namespace="conversate")), 21 | ] 22 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | clean 4 | py{36,37,38}-django{2.2,3.0,3.1} 5 | report 6 | 7 | [testenv] 8 | skipsdist=True 9 | usedevelop=True 10 | passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH 11 | setenv = 12 | PYTHONWARNINGS=default 13 | TOXENV={envname} 14 | depends = 15 | py{37,38}-django{2.2}: clean 16 | report: py{37,38}-django{2.2} 17 | deps = 18 | -rrequirements.txt 19 | coveralls 20 | django2.2: Django==2.2.* 21 | django3.0: Django==3.0.* 22 | django3.1: Django==3.1.* 23 | commands = 24 | pytest --cov-append {posargs} 25 | -coveralls 26 | 27 | [testenv:clean] 28 | deps = coverage 29 | skip_install = true 30 | commands = 31 | -coverage erase 32 | 33 | [testenv:report] 34 | deps = coverage 35 | skip_install = true 36 | commands = 37 | -coverage report 38 | -coverage html 39 | 40 | -------------------------------------------------------------------------------- /conversate/templates/conversate/includes/room_messages.html: -------------------------------------------------------------------------------- 1 | {% load conversate %} 2 | 3 |
4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% for message in room_messages %} 13 | 14 | 15 | 16 | 17 | 18 | {% endfor %} 19 |
  {% if remaining == 0 %}No older messages{% else %}Older messages hidden{% endif %}
{{ message.timestamp|naturaltimestamp }}{{ message.user.username }}{{ message.render|safe }}
20 |
21 |
22 | -------------------------------------------------------------------------------- /conversate/templates/conversate/room.html: -------------------------------------------------------------------------------- 1 | {% extends "conversate/base.html" %} 2 | {% load static %} 3 | 4 | {% block js %} 5 | {{ block.super }} 6 | 7 | {% endblock %} 8 | 9 | 10 | {% block conversate_content %} 11 |
12 | 13 | 14 | 17 | 18 |
19 |
20 | {% include "conversate/includes/room_messages.html" %} 21 |
22 | 23 | 26 |
27 |
28 | 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /conversate/templates/conversate/includes/room_input.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 25 | 26 |
   6 |
12 | {% csrf_token %} 13 | {{ form.content }} 14 | 15 |
16 | 17 | 18 |
19 | {{ form.file }} 20 |
21 |
22 | 23 |
24 |
27 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | """example URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.8/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Add an import: from blog import urls as blog_urls 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls)) 15 | """ 16 | from django.conf.urls import include, url 17 | from django.contrib import admin 18 | 19 | 20 | urlpatterns = [ 21 | url(r"^admin/", include(admin.site.urls)), 22 | url(r"^conversate/", include("conversate.urls", namespace="conversate")), 23 | ] 24 | -------------------------------------------------------------------------------- /example/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ title }} 6 | {% block css %} 7 | {% endblock %} 8 | 9 | {% block js %} 10 | {% endblock %} 11 | 12 | 31 | 32 | 33 | 34 | 35 | 36 |
37 |

{% block title %}{{ title }}{% endblock %}

38 |
39 | 40 |
41 | {% block content %} 42 | {% block conversate_content %} 43 | {% endblock %} 44 | {% endblock %} 45 |
46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "conversate", 3 | "version": "0.3.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "static_src/scripts/build.sh", 9 | "build-js": "static_src/scripts/build-js.sh", 10 | "build-css": "static_src/scripts/build-css.sh", 11 | "watch": "static_src/scripts/watch.sh", 12 | "watch-js": "static_src/scripts/watch-js.sh", 13 | "watch-css": "static_src/scripts/watch-css.sh" 14 | }, 15 | "dependencies": { 16 | "autosize": "^4.0.2", 17 | "babel-cli": "^6.26.0", 18 | "babel-preset-env": "^1.6.1", 19 | "babelify": "^8.0.0", 20 | "browserify": "^14.5.0", 21 | "event-stream": "^3.3.4", 22 | "include-media": "^1.4.9", 23 | "jquery": "^3.3.1", 24 | "node-sass": "^4.7.2", 25 | "resize-observer-polyfill": "^1.5.0" 26 | }, 27 | "browserify": { 28 | "transform": [ 29 | [ 30 | "babelify", 31 | { 32 | "presets": [ 33 | "env" 34 | ] 35 | } 36 | ] 37 | ] 38 | }, 39 | "author": "Richard Terry", 40 | "license": "BSD", 41 | "devDependencies": { 42 | "npm-run-all": "^4.1.2", 43 | "uglify-es": "^3.3.9", 44 | "uglifycss": "0.0.29", 45 | "uglifyify": "^5.0.0", 46 | "watchify": "^3.9.0" 47 | } 48 | } -------------------------------------------------------------------------------- /conversate/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Conversate utils 3 | """ 4 | import json 5 | import time 6 | 7 | from django.core.serializers.json import DjangoJSONEncoder 8 | from django.urls import reverse 9 | 10 | from . import settings 11 | 12 | 13 | def get_template_settings( 14 | room=None, 15 | room_user=None, 16 | extra=None, 17 | ): 18 | config = { 19 | "alertEnabled": room_user.alert if room_user else False, 20 | "serverTime": int(time.time()), 21 | } 22 | 23 | # Add API calls for room 24 | if room: 25 | config["apiCheck"] = reverse( 26 | "conversate:api_check", kwargs={"room_slug": room.slug} 27 | ) 28 | config["apiSend"] = reverse( 29 | "conversate:api_send", kwargs={"room_slug": room.slug} 30 | ) 31 | config["apiHistory"] = reverse( 32 | "conversate:api_history", kwargs={"room_slug": room.slug} 33 | ) 34 | 35 | # Allow for custom stuff 36 | if extra: 37 | config.update(extra) 38 | 39 | # Copy across settings 40 | for setting in dir(settings): 41 | if not setting.isupper(): 42 | continue 43 | titled = "".join(chunk.capitalize() for chunk in setting.split("_")) 44 | config[titled[:1].lower() + titled[1:]] = getattr(settings, setting) 45 | 46 | return { 47 | "config": json.dumps(config, cls=DjangoJSONEncoder), 48 | } 49 | -------------------------------------------------------------------------------- /conversate/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | Conversate URLs 3 | """ 4 | from django.urls import re_path 5 | 6 | from . import views 7 | 8 | 9 | app_name = "conversate" 10 | 11 | urlpatterns = [ 12 | re_path( 13 | r"^$", 14 | views.index, 15 | name="index", 16 | ), 17 | re_path( 18 | r"^(?P[-\w]+)/$", 19 | views.room, 20 | name="room", 21 | ), 22 | re_path( 23 | r"^(?P[-\w]+)/send/$", 24 | views.send, 25 | name="send", 26 | ), 27 | re_path( 28 | r"^(?P[-\w]+)/file/(?P\d+)/$", 29 | views.download_file, 30 | name="file", 31 | ), 32 | re_path( 33 | r"^(?P[-\w]+)/settings/$", 34 | views.update_settings, 35 | name="settings", 36 | ), 37 | # 38 | # JSON API 39 | # 40 | re_path( 41 | r"^(?P[-\w]+)/api/$", 42 | views.api_base, 43 | name="api_base", 44 | ), 45 | re_path( 46 | r"^(?P[-\w]+)/api/poll/$", 47 | views.api_check, 48 | name="api_check", 49 | ), 50 | re_path( 51 | r"^(?P[-\w]+)/api/send/$", 52 | views.api_send, 53 | name="api_send", 54 | ), 55 | re_path( 56 | r"^(?P[-\w]+)/api/history/$", 57 | views.api_history, 58 | name="api_history", 59 | ), 60 | ] 61 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: linux 2 | dist: xenial 3 | language: python 4 | install: 5 | - pip install --upgrade pip wheel setuptools 6 | - pip install --upgrade virtualenv tox 7 | script: 8 | - tox 9 | 10 | jobs: 11 | include: 12 | - python: '3.7' 13 | env: TOXENV=py37-django2.2 14 | - python: '3.7' 15 | env: TOXENV=py37-django3.0 16 | - python: '3.7' 17 | env: TOXENV=py37-django3.1 18 | - python: '3.8' 19 | env: TOXENV=py38-django2.2 20 | - python: '3.8' 21 | env: TOXENV=py38-django3.0 22 | - python: '3.8' 23 | env: TOXENV=py38-django3.1 24 | 25 | deploy: 26 | provider: pypi 27 | user: radiac 28 | password: 29 | secure: l85O2WTzuC2tSqYdQpO4dOo0UOxbgl3CgRLsXc8FT004haBol0efYnCjcIMMVkLDIoZeWXomEuFuQgCEfGkeTLnzU74I6NQzrXymRqH0JtZ5oEz2z/3Vb66/Q8gbwUUP3Cj8O7oKKS4yisdzEHeeY+WW/Vhlh5CtrHNr2zNyUsLzVH8etmA/bI40Ouoa3stSDJQA7bvX7uCpssyTey87DKuH95d5jrVWAYndZdilpWmRwlV8AWNLHk9noIU0mTKxRkzFlDoIHzCRP6gKxvCEmXkvCw4YYnT1SxaUImSHdPpj/WzalmdS6IORXDlTJKUlO+8cLh4/YRGgRvhcuhWk5OHnR4xbLE2Z4/lCNuvXVQUTCpTSZmS531G0U3xxpZSUrcF+XNbmYIvXRt1cd8Mh9DxjNbCCugYPhEG3EBbaBZu48RdIbFjAFW2zAcOg8Ygbasy9TKIECFn+bZ0GFKNtTRT7DlTb19f6PKmfTb3+joNsQxaeLg7xqKRAw+paoswk3DxlthgEBs+HSUo80OE/I5S8Hkdj0s8qUvPOE7RjL60ZW5r10WpsEqoLzg3W2aQQ2JyBc3RMmgB80uFaFmRFyRHxxC29c4iDJocyWERockL/VJwmKff2w4oPsJkvOCQAoC1um3L2UKgNTOHBWbW+uayLQOXEetMl5w2UI/6qO+k= 30 | skip_existing: true 31 | on: 32 | tags: true 33 | distributions: sdist bdist_wheel 34 | repo: radiac/django-conversate 35 | -------------------------------------------------------------------------------- /conversate/templatetags/conversate.py: -------------------------------------------------------------------------------- 1 | """ 2 | Conversate template tags 3 | """ 4 | import time 5 | 6 | from django import template 7 | 8 | 9 | register = template.Library() 10 | 11 | 12 | @register.filter 13 | def naturaltimestamp(value): 14 | """ 15 | For timestamp values shows how many seconds, minutes or hours ago 16 | compared to current timestamp 17 | 18 | Similar to naturaltime in django.contrib.humanize, but operates on 19 | a timestamp float instead of a datetime (which must be in the past), 20 | doesn't say "ago", and makes no attempt at i18n 21 | """ 22 | # Timestamp is a float, or could have been cast to an int 23 | if not isinstance(value, (float, int)): 24 | return value 25 | 26 | now = time.time() 27 | 28 | # Catch future values - server time has gone into the past 29 | if value > now: 30 | return "now" 31 | 32 | delta = int(now - value) 33 | 34 | return naturaltimedelta(delta) 35 | 36 | 37 | @register.filter 38 | def naturaltimedelta(delta): 39 | if delta < 0: 40 | return "never" 41 | 42 | elif delta < 60: 43 | term = "second" 44 | 45 | elif delta < 60 * 60: 46 | delta /= 60 47 | term = "minute" 48 | 49 | elif delta < 60 * 60 * 24: 50 | delta /= 60 * 60 51 | term = "hour" 52 | 53 | else: 54 | delta /= 60 * 60 * 24 55 | term = "day" 56 | 57 | return "%s %s%s" % ( 58 | int(delta), 59 | term, 60 | "" if delta == 1 else "s", 61 | ) 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | django-conversate is licensed under the BSD License 2 | =================================================== 3 | 4 | Copyright (c) 2020, Richard Terry, http://radiac.net/ 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 8 | 9 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 10 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 11 | Neither the name of the software nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 14 | -------------------------------------------------------------------------------- /conversate/templates/conversate/includes/room_sidebar.html: -------------------------------------------------------------------------------- 1 | {% load conversate %} 2 | 3 |

Users

4 | 5 | {% for user in room_users %} 6 | 7 | {% endfor %} 8 |
{{ user.username }}{{ user.last_spoke|naturaltimedelta }}
9 | 10 |

Settings

11 |
12 | {% csrf_token %} 13 |
14 | {{ settings.as_p }} 15 |
16 |
17 | 18 |
19 |
20 | 21 | 44 | -------------------------------------------------------------------------------- /conversate/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | # Number of lines to show on a page 5 | PAGE_SIZE = getattr(settings, "CONVERSATE_PAGE_SIZE", 100) 6 | 7 | # Time until UI decides user is idle (in ms) 8 | IDLE_AT = getattr(settings, "CONVERSATE_IDLE_AT", 60 * 1000) 9 | 10 | # Minimum poll interval (in ms) 11 | POLL_MIN = getattr(settings, "CONVERSATE_POLL_MIN", 5 * 1000) 12 | 13 | # Maximum poll interval (in ms) 14 | POLL_MAX = getattr(settings, "CONVERSATE_POLL_MAX", 60 * 1000) 15 | 16 | # Amount to increase poll interval by when there is no activity (in ms) 17 | POLL_STEP = getattr(settings, "CONVERSATE_POLL_STEP", 5 * 1000) 18 | 19 | # If True, Conversate's JavaScript will control the layout: 20 | # - changes the container element to position:relative and padding:0 21 | # - maximises container element's height into available space in the window 22 | # - makes the conversation scroll in place 23 | # - moves the input field to the bottom of the container 24 | CONTROL_LAYOUT = getattr(settings, "CONVERSATE_CONTROL_LAYOUT", True) 25 | 26 | # How long before marking the user as disconnected (in secs) 27 | # Defaults to POLL_MAX plus 30 seconds 28 | DISCONNECT_AT = getattr( 29 | settings, 30 | "CONVERSATE_DISCONNECT_AT", 31 | (POLL_MAX / 1000) + 30, 32 | ) 33 | 34 | # From address 35 | EMAIL_FROM = getattr( 36 | settings, 37 | "CONVERSATE_EMAIL_FROM", 38 | getattr(settings, "DEFAULT_FROM_EMAIL"), 39 | ) 40 | 41 | # Path to the private file store 42 | # 43 | # This should be a private dir outside the main site structure. 44 | # 45 | # Defaults to media root so it will work out of the box, but this has security 46 | # implications - permission checks can be bypassed. 47 | # Default: MEDIA_ROOT 48 | STORE_ROOT = getattr(settings, "CONVERSATE_STORE_ROOT", settings.MEDIA_ROOT) 49 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | ========================= 2 | Django Conversate Changes 3 | ========================= 4 | 5 | 6 | Upgrading 7 | ========= 8 | 9 | Upgrading from 0.1.0 10 | -------------------- 11 | 12 | * Add a namespace to your url import. 13 | * Only tested on Django 1.11 under Python 3.5. Patches to improve compatibility 14 | are welcome. 15 | * Styles have changed, you may need to update your templates 16 | 17 | 18 | Changelog 19 | ========= 20 | 21 | 0.3.1 2020-09-13 22 | Change: Documentation 23 | 24 | 0.3.0 2020-09-13 25 | Feature: Supports file uploads 26 | Change: Supports Django 2.2+ 27 | Change: Requires Python 3.6 28 | Bugfix: Server-side API errors return gracefully 29 | Security: Upstream JS fixes 30 | 31 | 0.2.0 2018-11-18 32 | Feature: Supports multi-line entry (shift+enter) and markdown 33 | Change: Minimum Django requirement now 1.11 34 | Change: Requires Python 3.5 35 | Change: Urls now namespaced 36 | Change: Times are now timezone aware 37 | Change: South migrations are removed 38 | Change: Layout simplified to use flexbox 39 | 40 | 0.1.0 2015-08-07 41 | Feature: Initial release 42 | 43 | 44 | Roadmap 45 | ======= 46 | 47 | * Support for websockets 48 | * Documented API for integration with third party apps 49 | * Improve room.updateTimes to update older entries 50 | * UI improvements 51 | * Room selection sidebar 52 | * API to automatically save settings changes 53 | * Admin options 54 | * Let users edit and delete their messages 55 | * Clear room without deleting it 56 | * UI for creating and managing rooms 57 | * Admin option to make a room public, so users can add themselves 58 | * User options 59 | * Separate flash and title visual alerts into two options 60 | * Provide custom e-mail address for alerts 61 | * Set alert time ranges 62 | * Colour picker 63 | 64 | The roadmap is subject to change; let me know if something is important to you. 65 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-conversate 3 | description = Persistant chat for Django 4 | long_description = file: README.rst 5 | keywords = django wiki 6 | author = Richard Terry 7 | author_email = code@radiac.net 8 | license = BSD 9 | classifiers = 10 | Development Status :: 4 - Beta 11 | Environment :: Web Environment 12 | License :: OSI Approved :: BSD License 13 | Operating System :: OS Independent 14 | Programming Language :: Python 15 | Programming Language :: Python :: 3 16 | Programming Language :: Python :: 3 :: Only 17 | Programming Language :: Python :: 3.6 18 | Programming Language :: Python :: 3.7 19 | Programming Language :: Python :: 3.8 20 | Framework :: Django 21 | Framework :: Django :: 2.2 22 | url = http://radiac.net/projects/django-conversate/ 23 | project_urls = 24 | Documentation = http://radiac.net/projects/django-conversate/ 25 | Source = https://github.com/radiac/django-conversate 26 | Tracker = https://github.com/radiac/django-conversate/issues 27 | 28 | [options] 29 | python_requires = >=3.6 30 | packages = find: 31 | install_requires = 32 | Django>=2.2 33 | django-yaa-settings 34 | commonmark 35 | emoji 36 | include_package_data = true 37 | zip_safe = false 38 | 39 | [options.packages.find] 40 | exclude = 41 | example* 42 | tests* 43 | 44 | [tool:pytest] 45 | addopts = --black --flake8 --isort --cov=conversate --cov-report=term --cov-report=html 46 | testpaths = 47 | tests 48 | conversate 49 | example 50 | DJANGO_SETTINGS_MODULE = tests.settings 51 | 52 | [flake8] 53 | max-line-length = 88 54 | ignore = E123,E128,E203,E231,E266,E501,W503 55 | exclude = .tox,.git,*/static/CACHE/*,docs,node_modules,static_root,tmp 56 | 57 | [isort] 58 | multi_line_output = 3 59 | line_length = 88 60 | known_django = django 61 | sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 62 | include_trailing_comma = True 63 | lines_after_imports = 2 64 | skip = .git,node_modules,.tox 65 | 66 | [coverage:report] 67 | omit=example 68 | 69 | [mypy] 70 | follow_imports = skip 71 | ignore_missing_imports = true 72 | 73 | [doc8] 74 | max-line-length = 88 75 | ignore-path = *.txt,.tox,node_modules 76 | -------------------------------------------------------------------------------- /conversate/decorators.py: -------------------------------------------------------------------------------- 1 | """ 2 | Conversate decorators 3 | """ 4 | 5 | from django.contrib.auth.decorators import user_passes_test 6 | from django.core.exceptions import PermissionDenied 7 | from django.http import JsonResponse 8 | from django.shortcuts import get_object_or_404 9 | 10 | from . import models 11 | 12 | 13 | def room_required(fn): 14 | """ 15 | View decorator to look up and validate a room slug 16 | Looks up room based on slug, or raises 404 17 | Checks the user has permission to access it, or raises PermissionDenied 18 | Passes Room instance to view as second argument 19 | """ 20 | 21 | def wrapper(request, *args, **kwargs): 22 | # TODO: This risks leaking room names 23 | room = get_object_or_404(models.Room, slug=kwargs.get("room_slug", None)) 24 | 25 | # Check logged in 26 | if not request.user.is_authenticated: 27 | # Make auth decorator fail to force user to login form 28 | return user_passes_test(lambda u: False)(lambda r: None)(request) 29 | 30 | # Check permission 31 | if ( 32 | not request.user.is_superuser 33 | and request.user.conversate_rooms.filter(slug=room.slug).count() == 0 34 | ): 35 | raise PermissionDenied 36 | 37 | # Permission granted 38 | return fn(request, room, *args, **kwargs) 39 | 40 | return wrapper 41 | 42 | 43 | def room_required_api(fn): 44 | """ 45 | API decorator for room checks, returns JSON responses instead of error states 46 | """ 47 | 48 | def wrapper(request, *args, **kwargs): 49 | try: 50 | room = models.Room.objects.get(slug=kwargs.get("room_slug", None)) 51 | except models.Room.DoesNotExist: 52 | return JsonResponse({"success": False, "error": "Room does not exist"}) 53 | 54 | # Check logged in 55 | if not request.user.is_authenticated: 56 | return JsonResponse({"success": False, "error": "You need to log in"}) 57 | 58 | # Check permission 59 | if ( 60 | not request.user.is_superuser 61 | and request.user.conversate_rooms.filter(slug=room.slug).count() == 0 62 | ): 63 | return JsonResponse( 64 | {"success": False, "error": "You do not have permission"} 65 | ) 66 | 67 | # Permission granted 68 | return fn(request, room, *args, **kwargs) 69 | 70 | return wrapper 71 | -------------------------------------------------------------------------------- /conversate/migrations/0002_auto_20181120_0655.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2018-11-20 06:55 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("conversate", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="room", 17 | name="slug", 18 | field=models.SlugField(help_text="Slug for the room", unique=True), 19 | ), 20 | migrations.AlterField( 21 | model_name="room", 22 | name="title", 23 | field=models.CharField(help_text="Title of the room", max_length=255), 24 | ), 25 | migrations.AlterField( 26 | model_name="roomuser", 27 | name="alert", 28 | field=models.BooleanField( 29 | default=True, help_text="Visual alert when activity while not focused" 30 | ), 31 | ), 32 | migrations.AlterField( 33 | model_name="roomuser", 34 | name="colour", 35 | field=models.CharField( 36 | default="000000", help_text="Hex colour code", max_length=6 37 | ), 38 | ), 39 | migrations.AlterField( 40 | model_name="roomuser", 41 | name="has_focus", 42 | field=models.BooleanField( 43 | default=False, help_text="If the user's window has focus" 44 | ), 45 | ), 46 | migrations.AlterField( 47 | model_name="roomuser", 48 | name="inactive_from", 49 | field=models.DateTimeField( 50 | blank=True, 51 | help_text="User has left if no poll before this time", 52 | null=True, 53 | ), 54 | ), 55 | migrations.AlterField( 56 | model_name="roomuser", 57 | name="last_mail_alert", 58 | field=models.DateTimeField( 59 | blank=True, help_text="Last time a mail alert was sent", null=True 60 | ), 61 | ), 62 | migrations.AlterField( 63 | model_name="roomuser", 64 | name="last_seen", 65 | field=models.DateTimeField(blank=True, help_text="Last seen", null=True), 66 | ), 67 | migrations.AlterField( 68 | model_name="roomuser", 69 | name="last_spoke", 70 | field=models.DateTimeField(blank=True, help_text="Last spoke", null=True), 71 | ), 72 | migrations.AlterField( 73 | model_name="roomuser", 74 | name="mail_alert", 75 | field=models.BooleanField( 76 | default=False, help_text="Send e-mail alert when activity while offline" 77 | ), 78 | ), 79 | ] 80 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.8.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.8/ref/settings/ 11 | """ 12 | 13 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 14 | import os 15 | 16 | 17 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = "fy76xss60$zonjoz59^t&aojm)1@qda13l1b1*jb0@x9l5k0yd" 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = ( 35 | "django.contrib.admin", 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.messages", 40 | "django.contrib.staticfiles", 41 | "conversate", 42 | ) 43 | 44 | MIDDLEWARE_CLASSES = ( 45 | "django.contrib.sessions.middleware.SessionMiddleware", 46 | "django.middleware.common.CommonMiddleware", 47 | "django.middleware.csrf.CsrfViewMiddleware", 48 | "django.contrib.auth.middleware.AuthenticationMiddleware", 49 | "django.contrib.auth.middleware.SessionAuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 52 | "django.middleware.security.SecurityMiddleware", 53 | ) 54 | 55 | ROOT_URLCONF = "example.urls" 56 | 57 | TEMPLATES = [ 58 | { 59 | "BACKEND": "django.template.backends.django.DjangoTemplates", 60 | "DIRS": ["templates"], 61 | "APP_DIRS": True, 62 | "OPTIONS": { 63 | "context_processors": [ 64 | "django.template.context_processors.debug", 65 | "django.template.context_processors.request", 66 | "django.contrib.auth.context_processors.auth", 67 | "django.contrib.messages.context_processors.messages", 68 | ], 69 | }, 70 | }, 71 | ] 72 | 73 | WSGI_APPLICATION = "example.wsgi.application" 74 | 75 | 76 | # Database 77 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 78 | 79 | DATABASES = { 80 | "default": { 81 | "ENGINE": "django.db.backends.sqlite3", 82 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 83 | } 84 | } 85 | 86 | 87 | # Internationalization 88 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 89 | 90 | LANGUAGE_CODE = "en-us" 91 | 92 | TIME_ZONE = "UTC" 93 | 94 | USE_I18N = True 95 | 96 | USE_L10N = True 97 | 98 | USE_TZ = True 99 | 100 | 101 | # Static files (CSS, JavaScript, Images) 102 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 103 | 104 | STATIC_URL = "/static/" 105 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testapp project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.15. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | 16 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 17 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = "secret" 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | "django.contrib.admin", 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.messages", 40 | "django.contrib.staticfiles", 41 | "conversate", 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | "django.middleware.security.SecurityMiddleware", 46 | "django.contrib.sessions.middleware.SessionMiddleware", 47 | "django.middleware.common.CommonMiddleware", 48 | "django.middleware.csrf.CsrfViewMiddleware", 49 | "django.contrib.auth.middleware.AuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 52 | ] 53 | 54 | ROOT_URLCONF = "tests.urls" 55 | 56 | TEMPLATES = [ 57 | { 58 | "BACKEND": "django.template.backends.django.DjangoTemplates", 59 | "DIRS": [], 60 | "APP_DIRS": True, 61 | "OPTIONS": { 62 | "context_processors": [ 63 | "django.template.context_processors.debug", 64 | "django.template.context_processors.request", 65 | "django.contrib.auth.context_processors.auth", 66 | "django.contrib.messages.context_processors.messages", 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | 73 | # Database 74 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 75 | 76 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 77 | 78 | 79 | # Password validation 80 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 81 | 82 | AUTH_PASSWORD_VALIDATORS = [ 83 | { 84 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 85 | }, 86 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 87 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 88 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 89 | ] 90 | 91 | 92 | # Internationalization 93 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 94 | 95 | LANGUAGE_CODE = "en-us" 96 | 97 | TIME_ZONE = "UTC" 98 | 99 | USE_I18N = True 100 | 101 | USE_L10N = True 102 | 103 | USE_TZ = True 104 | 105 | MEDIA_ROOT = os.path.join(BASE_DIR, "test_media") 106 | MEDIA_URL = "/media/" 107 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # To update, run: 4 | # 5 | # pip-compile 6 | # 7 | alabaster==0.7.12 # via sphinx 8 | appdirs==1.4.4 # via black, virtualenv 9 | asgiref==3.2.10 # via django 10 | attrs==20.2.0 # via pytest 11 | babel==2.8.0 # via sphinx 12 | black==20.8b1 # via -r requirements.in, pytest-black 13 | certifi==2020.6.20 # via requests 14 | chardet==3.0.4 # via requests 15 | click==7.1.2 # via black, pip-tools 16 | commonmark==0.9.1 # via -r requirements.in 17 | coverage==5.2.1 # via pytest-cov 18 | distlib==0.3.1 # via virtualenv 19 | django-yaa-settings==1.1.0 # via -r requirements.in 20 | #django==3.1.1 # via model-bakery 21 | docutils==0.16 # via sphinx 22 | emoji==0.6.0 # via -r requirements.in 23 | filelock==3.0.12 # via tox, virtualenv 24 | flake8==3.8.3 # via -r requirements.in, pytest-flake8 25 | idna==2.10 # via requests 26 | imagesize==1.2.0 # via sphinx 27 | iniconfig==1.0.1 # via pytest 28 | isort==5.5.2 # via -r requirements.in, pytest-isort 29 | jinja2==2.11.2 # via sphinx 30 | livereload==2.6.3 # via sphinx-autobuild 31 | markupsafe==1.1.1 # via jinja2 32 | mccabe==0.6.1 # via flake8 33 | model-bakery==1.1.1 # via -r requirements.in 34 | more-itertools==8.5.0 # via pytest 35 | mypy-extensions==0.4.3 # via black 36 | packaging==20.4 # via pytest, sphinx, tox 37 | pathspec==0.8.0 # via black 38 | pip-tools==5.3.1 # via -r requirements.in 39 | pluggy==0.13.1 # via pytest, tox 40 | py==1.9.0 # via pytest, tox 41 | pycodestyle==2.6.0 # via flake8 42 | pyflakes==2.2.0 # via flake8 43 | pygments==2.7.0 # via sphinx 44 | pyparsing==2.4.7 # via packaging 45 | pytest-black==0.3.11 # via -r requirements.in 46 | pytest-cov==2.10.1 # via -r requirements.in 47 | pytest-django==3.9.0 # via -r requirements.in 48 | pytest-flake8==1.0.6 # via -r requirements.in 49 | pytest-isort==1.2.0 # via -r requirements.in 50 | pytest==6.0.2 # via -r requirements.in, pytest-black, pytest-cov, pytest-django, pytest-flake8 51 | pytz==2020.1 # via babel, django 52 | regex==2020.7.14 # via black 53 | requests==2.24.0 # via sphinx 54 | six==1.15.0 # via livereload, packaging, pip-tools, tox, virtualenv 55 | snowballstemmer==2.0.0 # via sphinx 56 | sphinx-autobuild==2020.9.1 # via -r requirements.in 57 | sphinx==3.2.1 # via -r requirements.in, sphinx-autobuild 58 | sphinxcontrib-applehelp==1.0.2 # via sphinx 59 | sphinxcontrib-devhelp==1.0.2 # via sphinx 60 | sphinxcontrib-htmlhelp==1.0.3 # via sphinx 61 | sphinxcontrib-jsmath==1.0.1 # via sphinx 62 | sphinxcontrib-qthelp==1.0.3 # via sphinx 63 | sphinxcontrib-serializinghtml==1.1.4 # via sphinx 64 | sqlparse==0.3.1 # via django 65 | toml==0.10.1 # via black, pytest, pytest-black, tox 66 | tornado==6.0.4 # via livereload 67 | tox==3.20.0 # via -r requirements.in 68 | typed-ast==1.4.1 # via black 69 | typing-extensions==3.7.4.3 # via black 70 | urllib3==1.25.10 # via requests 71 | virtualenv==20.0.31 # via tox 72 | 73 | # The following packages are considered to be unsafe in a requirements file: 74 | # pip 75 | # setuptools 76 | -------------------------------------------------------------------------------- /conversate/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Conversate models 3 | """ 4 | import os 5 | import time 6 | 7 | from django.contrib.auth import get_user_model 8 | from django.core.files.storage import FileSystemStorage 9 | from django.db import models 10 | from django.urls import reverse 11 | from django.utils import html, timezone 12 | 13 | from commonmark import commonmark 14 | from emoji import emojize 15 | 16 | from . import settings 17 | 18 | 19 | User = get_user_model() 20 | 21 | 22 | class Room(models.Model): 23 | title = models.CharField( 24 | max_length=255, 25 | help_text="Title of the room", 26 | ) 27 | slug = models.SlugField( 28 | unique=True, 29 | help_text="Slug for the room", 30 | ) 31 | users = models.ManyToManyField( 32 | User, 33 | through="RoomUser", 34 | related_name="conversate_rooms", 35 | ) 36 | 37 | class Meta: 38 | ordering = ("title",) 39 | 40 | def __str__(self): 41 | return "%s" % self.title 42 | 43 | def get_absolute_url(self): 44 | return reverse("conversate:room", kwargs={"room_slug": self.slug}) 45 | 46 | 47 | class RoomUser(models.Model): 48 | room = models.ForeignKey(Room, on_delete=models.CASCADE) 49 | user = models.ForeignKey(User, on_delete=models.CASCADE) 50 | 51 | # State 52 | last_seen = models.DateTimeField( 53 | blank=True, 54 | null=True, 55 | help_text="Last seen", 56 | ) 57 | has_focus = models.BooleanField( 58 | default=False, 59 | help_text="If the user's window has focus", 60 | ) 61 | last_spoke = models.DateTimeField( 62 | blank=True, 63 | null=True, 64 | help_text="Last spoke", 65 | ) 66 | inactive_from = models.DateTimeField( 67 | blank=True, 68 | null=True, 69 | help_text="User has left if no poll before this time", 70 | ) 71 | last_mail_alert = models.DateTimeField( 72 | blank=True, 73 | null=True, 74 | help_text="Last time a mail alert was sent", 75 | ) 76 | 77 | # Preferences 78 | colour = models.CharField( 79 | max_length=6, 80 | help_text="Hex colour code", 81 | default="000000", 82 | ) 83 | alert = models.BooleanField( 84 | default=True, 85 | help_text="Visual alert when activity while not focused", 86 | ) 87 | mail_alert = models.BooleanField( 88 | default=False, 89 | help_text="Send e-mail alert when activity while offline", 90 | ) 91 | 92 | class Meta: 93 | ordering = ( 94 | "room", 95 | "user__username", 96 | ) 97 | 98 | def __str__(self): 99 | return "%s/%s" % (self.room, self.user) 100 | 101 | def is_active(self, now=None): 102 | if not now: 103 | now = timezone.now() 104 | if self.inactive_from and self.inactive_from >= now: 105 | return True 106 | return False 107 | 108 | def can_mail_alert(self, now=None): 109 | if not now: 110 | now = timezone.now() 111 | 112 | if ( 113 | self.mail_alert 114 | and self.user.email 115 | and not self.is_active(now) 116 | and (not self.last_mail_alert or self.last_seen > self.last_mail_alert) 117 | ): 118 | return True 119 | return False 120 | 121 | 122 | file_store = FileSystemStorage(location=settings.STORE_ROOT) 123 | 124 | 125 | def file_upload_to(item, filename): 126 | return os.path.join("conversate", item.room_id, filename) 127 | 128 | 129 | class Message(models.Model): 130 | room = models.ForeignKey(Room, related_name="messages", on_delete=models.CASCADE) 131 | user = models.ForeignKey( 132 | User, related_name="conversate_messages", on_delete=models.CASCADE 133 | ) 134 | timestamp = models.IntegerField() 135 | content = models.TextField() 136 | file = models.FileField( 137 | storage=file_store, upload_to="conversate", blank=True, null=True 138 | ) 139 | 140 | class Meta: 141 | ordering = ("timestamp",) 142 | 143 | def __str__(self): 144 | return "%s/%s %s" % (self.room, self.user, self.timestamp) 145 | 146 | def save(self, *args, **kwargs): 147 | """ 148 | Force timestamp to now 149 | """ 150 | if not self.timestamp: 151 | self.timestamp = int(time.time()) 152 | super(Message, self).save(*args, **kwargs) 153 | 154 | def get_file_url(self): 155 | return reverse( 156 | "conversate:file", 157 | kwargs={"room_slug": self.room.slug, "message_id": self.id}, 158 | ) 159 | 160 | def render(self): 161 | as_safe = html.escape(self.content) 162 | as_emoji = emojize(as_safe, use_aliases=True) 163 | as_html = commonmark(as_emoji) 164 | 165 | if self.file: 166 | if "." in self.file.name: 167 | ext = self.file.name.rsplit(".", 1)[1].lower() 168 | else: 169 | ext = "" 170 | 171 | if ext in ["jpg", "jpeg", "png", "gif"]: 172 | label = f'' 173 | else: 174 | label = self.file.name 175 | 176 | as_html += ( 177 | f'' 178 | f"{label}" 179 | ) 180 | return as_html 181 | -------------------------------------------------------------------------------- /conversate/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Message", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | verbose_name="ID", 22 | serialize=False, 23 | auto_created=True, 24 | primary_key=True, 25 | ), 26 | ), 27 | ("timestamp", models.IntegerField()), 28 | ("content", models.TextField()), 29 | ], 30 | options={ 31 | "ordering": ("timestamp",), 32 | }, 33 | ), 34 | migrations.CreateModel( 35 | name="Room", 36 | fields=[ 37 | ( 38 | "id", 39 | models.AutoField( 40 | verbose_name="ID", 41 | serialize=False, 42 | auto_created=True, 43 | primary_key=True, 44 | ), 45 | ), 46 | ( 47 | "title", 48 | models.CharField(help_text=b"Title of the room", max_length=255), 49 | ), 50 | ("slug", models.SlugField(help_text=b"Slug for the room", unique=True)), 51 | ], 52 | options={ 53 | "ordering": ("title",), 54 | }, 55 | ), 56 | migrations.CreateModel( 57 | name="RoomUser", 58 | fields=[ 59 | ( 60 | "id", 61 | models.AutoField( 62 | verbose_name="ID", 63 | serialize=False, 64 | auto_created=True, 65 | primary_key=True, 66 | ), 67 | ), 68 | ( 69 | "last_seen", 70 | models.DateTimeField(help_text=b"Last seen", null=True, blank=True), 71 | ), 72 | ( 73 | "has_focus", 74 | models.BooleanField( 75 | default=False, help_text=b"If the user's window has focus" 76 | ), 77 | ), 78 | ( 79 | "last_spoke", 80 | models.DateTimeField( 81 | help_text=b"Last spoke", null=True, blank=True 82 | ), 83 | ), 84 | ( 85 | "inactive_from", 86 | models.DateTimeField( 87 | help_text=b"User has left if no poll before this time", 88 | null=True, 89 | blank=True, 90 | ), 91 | ), 92 | ( 93 | "last_mail_alert", 94 | models.DateTimeField( 95 | help_text=b"Last time a mail alert was sent", 96 | null=True, 97 | blank=True, 98 | ), 99 | ), 100 | ( 101 | "colour", 102 | models.CharField( 103 | default=b"000000", help_text=b"Hex colour code", max_length=6 104 | ), 105 | ), 106 | ( 107 | "alert", 108 | models.BooleanField( 109 | default=True, 110 | help_text=b"Visual alert when activity while not focused", 111 | ), 112 | ), 113 | ( 114 | "mail_alert", 115 | models.BooleanField( 116 | default=False, 117 | help_text=b"Send e-mail alert when activity while offline", 118 | ), 119 | ), 120 | ( 121 | "room", 122 | models.ForeignKey( 123 | to="conversate.Room", 124 | on_delete=models.deletion.CASCADE, 125 | ), 126 | ), 127 | ( 128 | "user", 129 | models.ForeignKey( 130 | to=settings.AUTH_USER_MODEL, 131 | on_delete=models.deletion.CASCADE, 132 | ), 133 | ), 134 | ], 135 | options={ 136 | "ordering": ("room", "user__username"), 137 | }, 138 | ), 139 | migrations.AddField( 140 | model_name="room", 141 | name="users", 142 | field=models.ManyToManyField( 143 | related_name="conversate_rooms", 144 | through="conversate.RoomUser", 145 | to=settings.AUTH_USER_MODEL, 146 | ), 147 | ), 148 | migrations.AddField( 149 | model_name="message", 150 | name="room", 151 | field=models.ForeignKey( 152 | related_name="messages", 153 | to="conversate.Room", 154 | on_delete=models.deletion.CASCADE, 155 | ), 156 | ), 157 | migrations.AddField( 158 | model_name="message", 159 | name="user", 160 | field=models.ForeignKey( 161 | related_name="conversate_messages", 162 | to=settings.AUTH_USER_MODEL, 163 | on_delete=models.deletion.CASCADE, 164 | ), 165 | ), 166 | ] 167 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======================================= 2 | Conversate - Persistent chat for Django 3 | ======================================= 4 | 5 | A simple lightweight persistent chat app for Django sites, designed for users who may 6 | not always be around at the same time. 7 | 8 | * Project site: http://radiac.net/projects/django-conversate/ 9 | * Source code: https://github.com/radiac/django-conversate 10 | 11 | .. image:: https://travis-ci.org/radiac/django-conversate.svg?branch=master 12 | :target: https://travis-ci.org/radiac/django-conversate 13 | 14 | .. image:: https://coveralls.io/repos/radiac/django-conversate/badge.svg?branch=master&service=github 15 | :target: https://coveralls.io/github/radiac/django-conversate?branch=master 16 | 17 | 18 | Features 19 | ======== 20 | 21 | * Admins create rooms and add users to them 22 | * Messages are stored in the database 23 | * Full history is available to all users of that room 24 | * File upload support 25 | * Simple jquery-based polling for real-time updates 26 | * Users can opt in to receive e-mail alerts of activity when away 27 | * Support for users without javascript 28 | 29 | Supports Django 2.2+, on Python 3.6+. 30 | 31 | * See `CHANGES `_ for full changelog and roadmap 32 | * See `UPGRADE `_ for how to upgrade from earlier releases 33 | 34 | Please note: this is designed for small groups to have infrequent conversations 35 | which are often asynchronous; because each poll event will create a new HTTP 36 | connection, installations where a large number of concurrent users are expected 37 | should look for a different solution involving long-polling and websockets. 38 | 39 | There is an example site in the ``example`` directory. 40 | 41 | 42 | Installation 43 | ============ 44 | 45 | 1. Install ``django-conversate``:: 46 | 47 | pip install django-conversate 48 | 49 | 2. Add Conversate to ``INSTALLED_APPS``:: 50 | 51 | INSTALLED_APPS = ( 52 | ... 53 | 'conversate', 54 | ) 55 | 56 | You may also want to change some settings here (see `Settings`_ below) 57 | 58 | 3. Include the URLconf in your project's urls.py:: 59 | 60 | url(r'^conversate/', include('conversate.urls', namespace='conversate')), 61 | 62 | 4. Make sure your ``base.html`` template has the necessary blocks, or override 63 | Conversate's base, ``conversate/base.html`` (see `Templates and styles`_ below). You 64 | will also want to create a link somewhere to ``conversate-index`` (or 65 | ``conversate.views.index``) so users can access it. 66 | 67 | 5. Add the models to the database:: 68 | 69 | python manage.py migrate conversate 70 | 71 | 72 | Settings 73 | -------- 74 | 75 | Add these settings to your ``settings.py`` file to override the defaults. 76 | 77 | ``CONVERSATE_PAGE_SIZE`` 78 | Number of lines to show on a page 79 | 80 | Default: ``100`` 81 | 82 | ``CONVERSATE_IDLE_AT``: 83 | The time until the UI decides that the user is idle (in ms) 84 | 85 | Default: ``60*1000`` 86 | 87 | ``CONVERSATE_POLL_MIN``: 88 | Minimum poll interval (in ms) 89 | 90 | Default: ``5 * 1000`` 91 | 92 | ``CONVERSATE_POLL_MAX``: 93 | Maximum poll interval (in ms) 94 | 95 | Default: ``60 * 1000`` 96 | 97 | ``CONVERSATE_POLL_STEP``: 98 | Amount to increase poll interval by when there is no activity (in ms) 99 | 100 | Default: ``5 * 1000`` 101 | 102 | ``CONVERSATE_CONTROL_LAYOUT``: 103 | If True, Conversate's JavaScript will control the layout: 104 | * changes the container element to position:relative and padding:0 105 | * maximises container element's height into available space in the window 106 | * makes the conversation scroll in place 107 | * moves the input field to the bottom of the container 108 | 109 | Default: ``True`` 110 | 111 | ``CONVERSATE_DISCONNECT_AT``: 112 | How long before marking the user as disconnected (in secs) 113 | 114 | Defaults to POLL_MAX plus 30 seconds, ``60 + 30`` 115 | 116 | ``CONVERSATE_EMAIL_FROM``: 117 | From address for alert e-mails 118 | 119 | Default: ``DEFAULT_FROM_EMAIL`` (from main Django settings) 120 | 121 | ``CONVERSATE_STORE_ROOT``: 122 | Path to dir where uploaded files are stored. 123 | 124 | This should not be publicly accessible otherwise files could be downloaded without 125 | permission. The default value is the media root so that it works out of the box, but 126 | this is insecure as permission checks can be bypassed. 127 | 128 | Example: ``os.path.join(BASE_DIR, "..", "private")`` 129 | 130 | Default: ``MEDIA_ROOT`` 131 | 132 | 133 | Templates and styles 134 | -------------------- 135 | 136 | The Conversate templates extend ``conversate/base.html``, which in turn extends 137 | ``base.html``. The templates use HTML5 elements. 138 | 139 | They will expect the following blocks: 140 | 141 | * ``js`` for inserting JavaScript 142 | * ``css`` for inserting CSS 143 | * ``title`` for inserting the title (plain text) - or ``{{ title }}`` instead 144 | * ``content`` for the body content 145 | 146 | You will need to add these to your ``base.html`` template. Alternatively, if 147 | you already have the blocks but with different names, create 148 | ``conversate/base.html`` in your own templates folder and map them; for 149 | example:: 150 | 151 | {% block script %} 152 | {{ block.super }} 153 | {% block js %}{% endblock %} 154 | {% endblock %} 155 | 156 | Once you have mapped these blocks, the default settings and templates should 157 | work out of the box with most designs. However, the conversate container 158 | element in your site's base template should be given a fixed height and width 159 | to contain the chat interface. 160 | 161 | There is a single global JavaScript variable used, ``CONVERSATE``, which the 162 | template uses to pass settings and variables to the JavaScript. 163 | 164 | 165 | Usage 166 | ===== 167 | 168 | Set up one or more rooms in the Django admin site, and the rooms will be listed 169 | for your users on the conversate index page. 170 | 171 | Users can double-click the poll timer to force a faster poll. 172 | 173 | 174 | Credits 175 | ======= 176 | 177 | Thanks to all contributors, who are listed in CHANGES. 178 | 179 | This project includes bundled JavaScript dependencies. 180 | -------------------------------------------------------------------------------- /conversate/views.py: -------------------------------------------------------------------------------- 1 | """ 2 | Conversate views 3 | """ 4 | import datetime 5 | import time 6 | 7 | from django.contrib.auth.decorators import login_required 8 | from django.core.mail import send_mail 9 | from django.http import FileResponse, Http404, HttpResponseRedirect, JsonResponse 10 | from django.shortcuts import get_object_or_404, render 11 | from django.urls import reverse 12 | from django.utils import timezone 13 | 14 | from conversate import forms, models, settings, utils 15 | from conversate.decorators import room_required, room_required_api 16 | 17 | 18 | @login_required 19 | def index(request): 20 | """ 21 | List of rooms 22 | """ 23 | if request.user.is_superuser: 24 | rooms = models.Room.objects.all() 25 | else: 26 | rooms = request.user.conversate_rooms.all() 27 | 28 | return render( 29 | request, 30 | "conversate/index.html", 31 | { 32 | "conversate_settings": utils.get_template_settings(), 33 | "title": "Rooms", 34 | "rooms": rooms, 35 | }, 36 | ) 37 | 38 | 39 | @room_required 40 | def room(request, room, room_slug): 41 | """ 42 | Show a room 43 | """ 44 | # Get room messages 45 | room_user = _update_room_user(request, room) 46 | messages = room.messages.all() 47 | 48 | # Paginate 49 | msg_count = messages.count() 50 | remaining = 0 51 | if msg_count > settings.PAGE_SIZE: 52 | remaining = msg_count - settings.PAGE_SIZE 53 | messages = messages[remaining:] 54 | 55 | # Need all messages now, force lookup 56 | # Need last message for javascript 57 | messages = list(messages) 58 | first_message = messages[0] if len(messages) > 0 else None 59 | last_message = messages[-1] if len(messages) > 0 else None 60 | 61 | return render( 62 | request, 63 | "conversate/room.html", 64 | { 65 | "conversate_settings": utils.get_template_settings( 66 | room, 67 | room_user, 68 | extra={ 69 | "first": first_message.pk if first_message else None, 70 | "last": last_message.pk if last_message else None, 71 | "remaining": remaining, 72 | }, 73 | ), 74 | "title": room.title, 75 | "room": room, 76 | "form": forms.MessageForm(), 77 | "settings": forms.SettingsForm(instance=room_user), 78 | "room_messages": messages, 79 | "room_users": _room_users(room, request.user), 80 | }, 81 | ) 82 | 83 | 84 | @room_required 85 | def send(request, room, room_slug): 86 | """ 87 | Process a message submission 88 | """ 89 | spoke = False 90 | if request.POST: 91 | form = forms.MessageForm(request.POST, request.FILES) 92 | if form.is_valid(): 93 | message = form.save(commit=False) 94 | message.room = room 95 | message.user = request.user 96 | message.save() 97 | spoke = True 98 | 99 | _update_room_user(request, room, spoke=spoke) 100 | _mail_alert(request, room) 101 | 102 | return HttpResponseRedirect( 103 | reverse("conversate:room", kwargs={"room_slug": room_slug}) 104 | ) 105 | 106 | 107 | @room_required 108 | def download_file(request, room, room_slug, message_id): 109 | message = get_object_or_404(models.Message, room=room, id=message_id) 110 | if not message.file: 111 | return Http404() 112 | 113 | return FileResponse(message.file) 114 | 115 | 116 | @room_required 117 | def update_settings(request, room, room_slug): 118 | """ 119 | Process a settings change request 120 | """ 121 | room_user = _update_room_user(request, room) 122 | if room_user and request.POST: 123 | form = forms.SettingsForm(request.POST) 124 | if form.is_valid(): 125 | for key, val in form.cleaned_data.items(): 126 | setattr(room_user, key, val) 127 | room_user.save() 128 | 129 | return HttpResponseRedirect( 130 | reverse("conversate:room", kwargs={"room_slug": room_slug}) 131 | ) 132 | 133 | 134 | @room_required_api 135 | def api_base(request, room, room_slug): 136 | """ 137 | Base API URL 138 | Currently just used to reverse for JavaScript 139 | """ 140 | raise Http404 141 | 142 | 143 | @room_required_api 144 | def api_check(request, room, room_slug): 145 | """ 146 | API: check for new messages 147 | """ 148 | return _api_check(request, room, room_slug, last_pk=request.POST.get("last", None)) 149 | 150 | 151 | def _mail_alert(request, room): 152 | """ 153 | Send any mail alerts 154 | """ 155 | now = timezone.now() 156 | for room_user in models.RoomUser.objects.filter(room=room): 157 | if room_user.can_mail_alert(now): 158 | send_mail( 159 | "Conversate activity in %s" % room, 160 | ( 161 | 'There has been conversate activity in the room "%(room)s"' 162 | " since you last checked in.\n\n" 163 | " %(url)s\n" 164 | ) 165 | % { 166 | "room": room, 167 | "url": request.build_absolute_uri( 168 | reverse( 169 | "conversate:room", 170 | kwargs={"room_slug": room.slug}, 171 | ) 172 | ), 173 | }, 174 | settings.EMAIL_FROM, 175 | [room_user.user.email], 176 | fail_silently=True, 177 | ) 178 | room_user.last_mail_alert = now 179 | room_user.save() 180 | 181 | 182 | def _room_users(room, user): 183 | """ 184 | Internal function to get a list of other room users 185 | """ 186 | now = timezone.now() 187 | room_users = [] 188 | for room_user in models.RoomUser.objects.filter(room=room): 189 | # Calc last seen/spoke 190 | last_seen = -1 191 | last_spoke = -1 192 | if room_user.last_seen: 193 | last_seen_delta = now - room_user.last_seen 194 | last_seen = last_seen_delta.seconds + (last_seen_delta.days * 24 * 60 * 60) 195 | if room_user.last_spoke: 196 | last_spoke_delta = now - room_user.last_spoke 197 | last_spoke = last_spoke_delta.seconds + ( 198 | last_spoke_delta.days * 24 * 60 * 60 199 | ) 200 | 201 | room_users.append( 202 | { 203 | "username": room_user.user.username, 204 | "active": room_user.is_active(now=now), 205 | "has_focus": room_user.has_focus, 206 | "last_seen": last_seen, 207 | "last_spoke": last_spoke, 208 | "colour": room_user.colour, 209 | } 210 | ) 211 | return room_users 212 | 213 | 214 | def _update_room_user(request, room, has_focus=False, spoke=False): 215 | # Update user's last seen 216 | try: 217 | room_user = models.RoomUser.objects.get(room=room, user=request.user) 218 | except models.RoomUser.DoesNotExist: 219 | # Must be a superuser snooping 220 | return None 221 | now = timezone.now() 222 | room_user.last_seen = now 223 | room_user.inactive_from = now + datetime.timedelta(seconds=settings.DISCONNECT_AT) 224 | if spoke: 225 | room_user.last_spoke = now 226 | room_user.has_focus = has_focus 227 | room_user.save() 228 | return room_user 229 | 230 | 231 | def _api_check(request, room, room_slug, last_pk, spoke=False): 232 | """ 233 | Interal function to check for new messages and return a json response 234 | """ 235 | if not last_pk: 236 | return JsonResponse( 237 | { 238 | "success": False, 239 | "error": "No pointer provided", 240 | } 241 | ) 242 | 243 | # Register the ping 244 | has_focus = request.POST.get("hasFocus", "false") == "true" 245 | _update_room_user(request, room, has_focus, spoke) 246 | 247 | # Prep list of messages 248 | messages = list(room.messages.filter(pk__gt=last_pk)) 249 | data = [ 250 | { 251 | "pk": msg.pk, 252 | "time": msg.timestamp, 253 | "user": msg.user.username, 254 | "content": msg.render(), 255 | } 256 | for msg in messages 257 | ] 258 | 259 | return JsonResponse( 260 | { 261 | "success": True, 262 | "last": last_pk if not messages else messages[-1].pk, 263 | "time": int(time.time()), 264 | "messages": data, 265 | "users": _room_users(room, request.user), 266 | } 267 | ) 268 | 269 | 270 | @room_required_api 271 | def api_send(request, room, room_slug): 272 | """ 273 | API: send message 274 | """ 275 | if request.POST: 276 | form = forms.MessageForm(request.POST, request.FILES) 277 | if form.is_valid(): 278 | message = form.save(commit=False) 279 | message.room = room 280 | message.user = request.user 281 | message.save() 282 | 283 | # Get response and update users before sending mail alerts 284 | response = _api_check( 285 | request, 286 | room, 287 | room_slug, 288 | last_pk=request.POST.get("last", None), 289 | spoke=True, 290 | ) 291 | _mail_alert(request, room) 292 | 293 | return response 294 | else: 295 | error = "Invalid message" 296 | else: 297 | error = "Invalid request" 298 | 299 | return JsonResponse({"success": False, "error": error}) 300 | 301 | 302 | @room_required_api 303 | def api_history(request, room, room_slug): 304 | first_pk = request.POST.get("first", None) 305 | messages = room.messages.filter(pk__lt=first_pk) 306 | 307 | # Paginate 308 | msg_count = messages.count() 309 | remaining = 0 310 | if msg_count > settings.PAGE_SIZE: 311 | remaining = msg_count - settings.PAGE_SIZE 312 | messages = messages[remaining:] 313 | 314 | # Need all messages now, force lookup 315 | # Need last message for javascript 316 | messages = list(messages) 317 | first_message = messages[0] if len(messages) > 0 else None 318 | data = [ 319 | { 320 | "pk": msg.pk, 321 | "time": msg.timestamp, 322 | "user": msg.user.username, 323 | "content": msg.render(), 324 | } 325 | for msg in messages 326 | ] 327 | 328 | return JsonResponse( 329 | { 330 | "success": True, 331 | "first": first_message.pk, 332 | "remaining": remaining, 333 | "messages": data, 334 | } 335 | ) 336 | -------------------------------------------------------------------------------- /conversate/static/conversate/css/styles.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | /* 3 | ** CSS for Conversate 4 | ** All elements are prefixed with cnv_ 5 | */ 6 | /* Library mixins */ 7 | /* Layout */ 8 | .cnv_container { 9 | /* children are in flex column */ 10 | display: flex; 11 | flex-flow: row nowrap; } 12 | .cnv_container .cnv_sidebar { 13 | flex: 0 0 300px; 14 | order: 2; 15 | height: 100%; 16 | overflow-y: auto; } 17 | .cnv_container .cnv_content { 18 | /* grow and shrink regardless of content */ 19 | flex: 1 1 auto; 20 | order: 1; 21 | /* fixed height */ 22 | height: 100%; 23 | /* children are in flex row */ 24 | display: flex; 25 | flex-flow: column nowrap; } 26 | .cnv_container .cnv_content .cnv_messages { 27 | flex: 1 1 auto; 28 | height: 0; 29 | overflow-y: auto; } 30 | .cnv_container .cnv_content .cnv_messages .cnv_messages_inner { 31 | /* stick table to bottom */ 32 | display: table; 33 | height: 100%; 34 | width: 100%; } 35 | .cnv_container .cnv_content .cnv_messages .cnv_messages_inner .cnv_table_con { 36 | display: table-cell; 37 | vertical-align: bottom; } 38 | .cnv_container .cnv_content .cnv_messages table, .cnv_container .cnv_content .cnv_input table { 39 | width: 100%; } 40 | .cnv_container .cnv_content .cnv_input { 41 | flex: 0 0 auto; } 42 | .cnv_container .cnv_content .cnv_input table td { 43 | position: relative; } 44 | .cnv_container .cnv_content .cnv_input table form { 45 | display: flex; } 46 | .cnv_container .cnv_content .cnv_input table form textarea[name=content] { 47 | width: 100%; } 48 | .cnv_container .cnv_content .cnv_input table form .cnv_input__file { 49 | flex: 0 0 auto; 50 | width: 2rem; } 51 | .cnv_container .cnv_content .cnv_input table form .cnv_input__file > input[type="checkbox"] + label { 52 | display: block; 53 | overflow: hidden; 54 | position: absolute; 55 | visibility: hidden; 56 | z-index: 3; } 57 | .cnv_container .cnv_content .cnv_input table form .cnv_input__file > input[type="checkbox"] + label::after { 58 | content: "📎"; 59 | visibility: visible; 60 | display: block; 61 | position: absolute; 62 | text-align: center; } 63 | .cnv_container .cnv_content .cnv_input table form .cnv_input__file > input[type="checkbox"]:checked + label::after { 64 | content: "▾"; } 65 | .cnv_container .cnv_content .cnv_input table form .cnv_input__file > input[type="checkbox"] { 66 | display: none; } 67 | .cnv_container .cnv_content .cnv_input table form .cnv_input__file > input[type="checkbox"] + label { 68 | width: 2rem; 69 | height: 2rem; 70 | font-size: 1.5rem; 71 | line-height: 2rem; } 72 | .cnv_container .cnv_content .cnv_input table form .cnv_input__file > input[type="checkbox"] + label::after { 73 | top: 0; 74 | left: 0; 75 | width: 100%; 76 | height: 100%; } 77 | .cnv_container .cnv_content .cnv_input table form .cnv_input__file > input[type="checkbox"] ~ div { 78 | display: none; 79 | position: absolute; 80 | right: 0; 81 | top: -2.4rem; 82 | height: 2.4rem; 83 | line-height: 2rem; 84 | padding: 0.2rem; 85 | border: 1px solid #ccc; 86 | border-bottom: 0; 87 | border-radius: 5px 5px 0 0; } 88 | .cnv_container .cnv_content .cnv_input table form .cnv_input__file > input[type="checkbox"] ~ div input { 89 | display: block; } 90 | .cnv_container .cnv_content .cnv_input table form .cnv_input__file > input[type="checkbox"]:checked ~ div { 91 | display: block; } 92 | 93 | /* 94 | ** Styles 95 | */ 96 | .cnv_content table { 97 | border-collapse: collapse; } 98 | .cnv_content table td { 99 | vertical-align: top; 100 | border-bottom: 1px solid #eee; 101 | line-height: 1.2rem; 102 | padding: 0.1rem 0.4rem; 103 | box-sizing: border-box; } 104 | .cnv_content table td:nth-child(1) { 105 | font-size: 0.625rem; 106 | width: 4rem; 107 | color: #888; 108 | border-right: 1px solid #ccc; } 109 | .cnv_content table td:nth-child(2) { 110 | width: 7rem; 111 | border-right: 1px solid #eee; } 112 | .cnv_content table td:nth-child(-n+2) { 113 | white-space: nowrap; 114 | overflow: hidden; 115 | text-overflow: ellipsis; } 116 | .cnv_content table td:nth-child(3) { 117 | word-wrap: break-word; } 118 | .cnv_content table td .cnv_file { 119 | display: block; 120 | border: 1px solid #ccc; 121 | border-radius: 5px; 122 | background-color: #eee; 123 | padding: 5px; 124 | margin: 1rem 3rem; 125 | width: max-content; 126 | max-width: 500px; } 127 | .cnv_content table td .cnv_file img { 128 | max-width: 100%; 129 | max-height: 30vh; 130 | display: block; 131 | margin: 0; 132 | padding: 0; 133 | border: 0; } 134 | .cnv_content table tr.cnv_ERROR td { 135 | color: #fff; 136 | background: #c00; } 137 | 138 | .cnv_content .cnv_input td { 139 | background-color: #eee; 140 | border-bottom: 0; } 141 | 142 | .cnv_content .cnv_input textarea { 143 | border: 0; 144 | padding: 0.4rem; } 145 | 146 | .cnv_content .cnv_input td:first-child { 147 | padding: 0; 148 | position: relative; 149 | border-right: 1px solid #888; } 150 | .cnv_content .cnv_input td:first-child .cnv_hourglass_con { 151 | position: absolute; 152 | top: 0; 153 | bottom: 0; 154 | left: 0; 155 | right: 0; } 156 | .cnv_content .cnv_input td:first-child .cnv_hourglass { 157 | background: #ccc; 158 | height: 100%; } 159 | .cnv_content .cnv_input td:first-child .cnv_hourglass_note { 160 | position: absolute; 161 | top: 0; 162 | bottom: 0; 163 | left: 0; 164 | right: 0; 165 | padding: 2px; 166 | text-align: center; 167 | -webkit-touch-callout: none; 168 | -webkit-user-select: none; 169 | -khtml-user-select: none; 170 | -moz-user-select: none; 171 | -ms-user-select: none; 172 | user-select: none; } 173 | 174 | .cnv_content .cnv_input .cnv_input__file div { 175 | background: #eee; } 176 | 177 | .cnv_container { 178 | position: relative; } 179 | .cnv_container > input[type="checkbox"] + label { 180 | display: block; 181 | overflow: hidden; 182 | position: absolute; 183 | visibility: hidden; 184 | z-index: 3; } 185 | .cnv_container > input[type="checkbox"] + label::after { 186 | content: "×"; 187 | visibility: visible; 188 | display: block; 189 | position: absolute; 190 | text-align: center; } 191 | .cnv_container > input[type="checkbox"]:checked + label::after { 192 | content: "◂"; } 193 | .cnv_container > input[type="checkbox"] { 194 | display: none; } 195 | .cnv_container > input[type="checkbox"] + label { 196 | position: absolute; 197 | top: 0; 198 | right: 0; 199 | width: 2rem; 200 | height: 2rem; 201 | font-size: 1.5rem; 202 | line-height: 2rem; } 203 | .cnv_container > input[type="checkbox"] + label::after { 204 | top: 0; 205 | left: 0; 206 | width: 100%; 207 | height: 100%; } 208 | .cnv_container > input[type="checkbox"] ~ .cnv_sidebar { 209 | display: block; } 210 | .cnv_container > input[type="checkbox"]:checked ~ .cnv_sidebar { 211 | display: none; } 212 | 213 | .cnv_sidebar { 214 | background-color: #ddd; 215 | padding: 1rem; } 216 | .cnv_sidebar h1 { 217 | font-size: 1.2rem; 218 | margin: 1.2rem 0 0.8rem; } 219 | .cnv_sidebar h1:first-child { 220 | margin-top: 0; } 221 | .cnv_sidebar .cnv_users { 222 | width: 100%; } 223 | .cnv_sidebar .cnv_users th { 224 | text-align: left; } 225 | .cnv_sidebar .cnv_users td { 226 | font-size: 0.8em; 227 | width: 5rem; 228 | padding-left: 0.4rem; } 229 | .cnv_sidebar .cnv_users th, .cnv_sidebar .cnv_users td { 230 | border-bottom: 1px solid #ccc; } 231 | .cnv_sidebar .cnv_users tr:last-child th, .cnv_sidebar .cnv_users tr:last-child td { 232 | border-bottom: 0; } 233 | .cnv_sidebar .cnv_users tr.cnv_active td { 234 | border-left: 8px solid #6a4; } 235 | .cnv_sidebar .cnv_users tr.cnv_afk td { 236 | border-left: 8px solid #fa0; } 237 | .cnv_sidebar .cnv_users tr.cnv_inactive td { 238 | border-left: 8px solid #a54; } 239 | .cnv_sidebar .cnv_submit { 240 | width: 100%; 241 | overflow: hidden; } 242 | .cnv_sidebar .cnv_submit input[type="submit"] { 243 | float: right; } 244 | .cnv_sidebar .cnv_settings { 245 | list-style: none; 246 | padding: 0; 247 | margin: 0; } 248 | .cnv_sidebar .cnv_settings p { 249 | margin-bottom: 0.5em; } 250 | .cnv_sidebar .cnv_settings label { 251 | display: inline-block; 252 | width: 6em; } 253 | .cnv_sidebar .cnv_settings .helptext { 254 | display: block; 255 | font-size: 0.8em; 256 | margin-left: 2.5em; } 257 | 258 | @media all and (max-width: 640px) { 259 | .cnv_container > input[type="checkbox"] + label::after { 260 | content: "◂"; } 261 | .cnv_container > input[type="checkbox"] ~ .cnv_sidebar { 262 | display: none; } 263 | .cnv_container > input[type="checkbox"] ~ .cnv_content { 264 | display: flex; } 265 | .cnv_container > input[type="checkbox"]:checked + label::after { 266 | content: "×"; } 267 | .cnv_container > input[type="checkbox"]:checked ~ .cnv_sidebar { 268 | display: block; } 269 | .cnv_container > input[type="checkbox"]:checked ~ .cnv_content { 270 | display: none; } 271 | .cnv_sidebar { 272 | display: none; } 273 | .cnv_content table { 274 | display: block; } 275 | .cnv_content table tr { 276 | display: flex; 277 | flex-flow: row wrap; } 278 | .cnv_content table tr td:nth-child(-n+2) { 279 | width: auto; } 280 | .cnv_content table tr td:nth-child(-n+2) { 281 | display: block; 282 | border: 0; } 283 | .cnv_content table tr td:nth-child(1) { 284 | flex: 1 1 auto; } 285 | .cnv_content table tr td:nth-child(2) { 286 | flex: 1 1 auto; } 287 | .cnv_content table tr td:nth-child(3) { 288 | display: block; 289 | width: 100%; } 290 | .cnv_content .cnv_messages table tr td:nth-child(1) { 291 | order: 2; 292 | flex: 1 1 auto; } 293 | .cnv_content .cnv_messages table tr td:nth-child(2) { 294 | order: 1; 295 | flex: 0 0 auto; 296 | font-weight: bold; } 297 | .cnv_content .cnv_messages table tr td:nth-child(3) { 298 | order: 3; 299 | display: block; 300 | width: 100%; 301 | padding-left: 2rem; } } 302 | -------------------------------------------------------------------------------- /static_src/scss/styles.scss: -------------------------------------------------------------------------------- 1 | /* 2 | ** CSS for Conversate 3 | ** All elements are prefixed with cnv_ 4 | */ 5 | 6 | /* Library mixins */ 7 | 8 | @mixin toggler-base( 9 | $content-open: $char-arrow-down, 10 | $content-close: $char-arrow-up, 11 | $checkbox-selector: '', 12 | ) { 13 | & > input[type="checkbox"]#{$checkbox-selector} { 14 | & + label { 15 | display: block; 16 | overflow: hidden; 17 | position: absolute; 18 | visibility: hidden; 19 | z-index: 3; 20 | 21 | &::after { 22 | content: $content-open; 23 | visibility: visible; 24 | display: block; 25 | position: absolute; 26 | text-align: center; 27 | } 28 | } 29 | 30 | &:checked { 31 | & + label { 32 | &::after { 33 | content: $content-close; 34 | } 35 | } 36 | } 37 | } 38 | } 39 | 40 | 41 | // Character codes 42 | $char-burger: '\2261'; 43 | $char-cross: '\00D7'; 44 | $char-arrow-up: '\25B4'; 45 | $char-arrow-right: '\25B8'; 46 | $char-arrow-down: '\25BE'; 47 | $char-arrow-left: '\25C2'; 48 | $char-attachment: '\1F4CE'; 49 | 50 | $colour-sidebar-bg: #ddd !default; 51 | $colour-input-bg: #eee !default; 52 | $colour-file-bg: #eee !default; 53 | 54 | 55 | /* Layout */ 56 | .cnv_container { 57 | /* children are in flex column */ 58 | display: flex; 59 | flex-flow: row nowrap; 60 | 61 | .cnv_sidebar { 62 | flex: 0 0 300px; 63 | order: 2; 64 | 65 | height: 100%; 66 | overflow-y: auto; 67 | } 68 | 69 | .cnv_content { 70 | /* grow and shrink regardless of content */ 71 | flex: 1 1 auto; 72 | order: 1; 73 | 74 | /* fixed height */ 75 | height: 100%; 76 | 77 | /* children are in flex row */ 78 | display: flex; 79 | flex-flow: column nowrap; 80 | 81 | .cnv_messages { 82 | flex: 1 1 auto; 83 | height: 0; 84 | overflow-y: auto; 85 | 86 | .cnv_messages_inner { 87 | /* stick table to bottom */ 88 | display: table; 89 | height: 100%; 90 | width: 100%; 91 | 92 | .cnv_table_con { 93 | display: table-cell; 94 | vertical-align: bottom; 95 | } 96 | } 97 | } 98 | 99 | .cnv_messages, .cnv_input { 100 | table { 101 | width: 100%; 102 | } 103 | } 104 | 105 | .cnv_input { 106 | flex: 0 0 auto; 107 | 108 | table { 109 | td { 110 | position: relative; 111 | } 112 | form { 113 | display: flex; 114 | 115 | textarea[name=content] { 116 | width: 100%; 117 | } 118 | 119 | .cnv_input__file { 120 | flex: 0 0 auto; 121 | @include toggler-base( 122 | $content-open: $char-attachment, 123 | $content-close: $char-arrow-down, 124 | ); 125 | 126 | width: 2rem; 127 | 128 | & > input[type="checkbox"] { 129 | display: none; 130 | 131 | & + label { 132 | width: 2rem; 133 | height: 2rem; 134 | font-size: 1.5rem; 135 | line-height: 2rem; 136 | 137 | &::after { 138 | top: 0; 139 | left: 0; 140 | width: 100%; 141 | height: 100%; 142 | 143 | } 144 | } 145 | 146 | // Hidden by default 147 | & ~ div { 148 | display: none; 149 | position: absolute; 150 | right: 0; 151 | top: -2.4rem; 152 | height: 2.4rem; 153 | line-height: 2rem; 154 | padding: 0.2rem; 155 | border: 1px solid #ccc; 156 | border-bottom: 0; 157 | border-radius: 5px 5px 0 0; 158 | 159 | input { 160 | display: block; 161 | } 162 | } 163 | 164 | &:checked { 165 | & ~ div { 166 | display: block; 167 | } 168 | } 169 | } 170 | } 171 | } 172 | } 173 | } 174 | } 175 | } 176 | 177 | 178 | /* 179 | ** Styles 180 | */ 181 | 182 | .cnv_content { 183 | table { 184 | border-collapse: collapse; 185 | 186 | td { 187 | vertical-align: top; 188 | border-bottom: 1px solid #eee; 189 | line-height: 1.2rem; 190 | padding: 0.1rem 0.4rem; 191 | //overflow: hidden; 192 | box-sizing: border-box; 193 | 194 | &:nth-child(1) { 195 | font-size: 0.625rem; 196 | width: 4rem; 197 | color: #888; 198 | border-right: 1px solid #ccc; 199 | } 200 | 201 | &:nth-child(2) { 202 | width: 7rem; 203 | border-right: 1px solid #eee; 204 | } 205 | 206 | &:nth-child(-n+2) { 207 | white-space: nowrap; 208 | overflow: hidden; 209 | text-overflow: ellipsis; 210 | } 211 | 212 | &:nth-child(3) { 213 | word-wrap: break-word; 214 | } 215 | 216 | .cnv_file { 217 | display: block; 218 | border: 1px solid #ccc; 219 | border-radius: 5px; 220 | background-color: #eee; 221 | padding: 5px; 222 | margin: 1rem 3rem; 223 | width: max-content; 224 | max-width: 500px; 225 | 226 | img { 227 | max-width: 100%; 228 | max-height: 30vh; 229 | display: block; 230 | margin: 0; 231 | padding: 0; 232 | border: 0; 233 | } 234 | } 235 | } 236 | 237 | tr.cnv_ERROR td { 238 | color: #fff; 239 | background: #c00; 240 | } 241 | } 242 | 243 | .cnv_input { 244 | td { 245 | background-color: $colour-input-bg; 246 | border-bottom: 0; 247 | } 248 | 249 | textarea { 250 | border: 0; 251 | padding: 0.4rem; 252 | } 253 | 254 | td:first-child { 255 | padding: 0; 256 | position: relative; 257 | border-right: 1px solid #888; 258 | 259 | .cnv_hourglass_con { 260 | position: absolute; 261 | top: 0; 262 | bottom: 0; 263 | left: 0; 264 | right: 0; 265 | } 266 | 267 | .cnv_hourglass { 268 | background: #ccc; 269 | height: 100%; 270 | } 271 | 272 | .cnv_hourglass_note { 273 | position: absolute; 274 | top: 0; 275 | bottom: 0; 276 | left: 0; 277 | right: 0; 278 | padding: 2px; 279 | text-align: center; 280 | -webkit-touch-callout: none; 281 | -webkit-user-select: none; 282 | -khtml-user-select: none; 283 | -moz-user-select: none; 284 | -ms-user-select: none; 285 | user-select: none; 286 | } 287 | } 288 | 289 | .cnv_input__file { 290 | div { 291 | background: $colour-file-bg; 292 | } 293 | } 294 | } 295 | } 296 | 297 | .cnv_container { 298 | @include toggler-base( 299 | $content-open: $char-cross, 300 | $content-close: $char-arrow-left, 301 | ); 302 | position: relative; 303 | 304 | & > input[type="checkbox"] { 305 | display: none; 306 | 307 | & + label { 308 | position: absolute; 309 | top: 0; 310 | right: 0; 311 | width: 2rem; 312 | height: 2rem; 313 | font-size: 1.5rem; 314 | line-height: 2rem; 315 | 316 | &::after { 317 | top: 0; 318 | left: 0; 319 | width: 100%; 320 | height: 100%; 321 | 322 | } 323 | } 324 | 325 | // Desktop is shown by default 326 | & ~ .cnv_sidebar { 327 | display: block; 328 | } 329 | 330 | &:checked { 331 | & ~ .cnv_sidebar { 332 | display: none; 333 | } 334 | } 335 | } 336 | } 337 | 338 | .cnv_sidebar { 339 | background-color: $colour-sidebar-bg; 340 | padding: 1rem; 341 | 342 | h1 { 343 | font-size: 1.2rem; 344 | margin: 1.2rem 0 0.8rem; 345 | 346 | &:first-child { 347 | margin-top: 0; 348 | } 349 | } 350 | 351 | .cnv_users { 352 | width: 100%; 353 | 354 | th { 355 | text-align: left; 356 | } 357 | 358 | td { 359 | font-size: 0.8em; 360 | width: 5rem; 361 | padding-left: 0.4rem; 362 | } 363 | 364 | th, td { 365 | border-bottom: 1px solid #ccc; 366 | } 367 | 368 | tr:last-child { 369 | th, td { 370 | border-bottom: 0; 371 | } 372 | } 373 | 374 | tr.cnv_active td { 375 | border-left: 8px solid #6a4; 376 | } 377 | 378 | tr.cnv_afk td { 379 | border-left: 8px solid #fa0; 380 | } 381 | 382 | tr.cnv_inactive td { 383 | border-left: 8px solid #a54; 384 | } 385 | } 386 | 387 | 388 | .cnv_submit { 389 | width: 100%; 390 | overflow: hidden; 391 | 392 | input[type="submit"] { 393 | float: right; 394 | } 395 | } 396 | 397 | .cnv_settings { 398 | list-style: none; 399 | padding: 0; 400 | margin: 0; 401 | 402 | p { 403 | margin-bottom: 0.5em; 404 | } 405 | 406 | label { 407 | display: inline-block; 408 | width: 6em; 409 | } 410 | 411 | .helptext { 412 | display: block; 413 | font-size: 0.8em; 414 | margin-left: 2.5em; 415 | } 416 | } 417 | } 418 | 419 | 420 | @media all and (max-width:640px) { 421 | // Mobile sidebar is closed by default 422 | .cnv_container { 423 | & > input[type="checkbox"] { 424 | & + label::after { 425 | content: $char-arrow-left; 426 | } 427 | 428 | & ~ .cnv_sidebar { 429 | display: none; 430 | } 431 | 432 | & ~ .cnv_content { 433 | display: flex; 434 | } 435 | 436 | &:checked { 437 | & + label::after { 438 | content: $char-cross; 439 | } 440 | 441 | & ~ .cnv_sidebar { 442 | display: block; 443 | } 444 | 445 | & ~ .cnv_content { 446 | display: none; 447 | } 448 | } 449 | } 450 | } 451 | 452 | .cnv_sidebar { 453 | display: none; 454 | } 455 | 456 | .cnv_content { 457 | table { 458 | display: block; 459 | tr { 460 | display: flex; 461 | flex-flow: row wrap; 462 | 463 | td:nth-child(-n+2) { 464 | width: auto; 465 | } 466 | td:nth-child(-n+2) { 467 | display: block; 468 | border: 0; 469 | } 470 | td:nth-child(1) { 471 | flex: 1 1 auto; 472 | } 473 | td:nth-child(2) { 474 | flex: 1 1 auto; 475 | } 476 | td:nth-child(3) { 477 | display: block; 478 | width: 100%; 479 | } 480 | } 481 | } 482 | 483 | .cnv_messages { 484 | table { 485 | tr { 486 | td:nth-child(1) { 487 | order: 2; 488 | flex: 1 1 auto; 489 | } 490 | td:nth-child(2) { 491 | order: 1; 492 | flex: 0 0 auto; 493 | font-weight: bold; 494 | } 495 | td:nth-child(3) { 496 | order: 3; 497 | display: block; 498 | width: 100%; 499 | padding-left: 2rem; 500 | } 501 | } 502 | } 503 | } 504 | } 505 | } 506 | -------------------------------------------------------------------------------- /static_src/js/room.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import autosize from 'autosize'; 3 | import ResizeObserver from 'resize-observer-polyfill'; 4 | 5 | 6 | /* 7 | ** JavaScript for Conversate 8 | */ 9 | 10 | $('document').ready(function () { 11 | 12 | /************************************************************************** 13 | *********************************************************** Global vars 14 | **************************************************************************/ 15 | 16 | // Settings 17 | var settings = $.extend({ 18 | // API urls 19 | apiCheck: null, 20 | apiSend: null, 21 | apiHistory: null, 22 | 23 | // PK of last message 24 | last: 0, 25 | 26 | // If True, will alert when changes occur while window blurred 27 | alertEnabled: false, 28 | 29 | // Server time, for initial time updates 30 | serverTime: time(), 31 | 32 | // Settings taken from converse.settings 33 | idleAt: 60 * 1000, 34 | pollMin: 5 * 1000, 35 | pollMax: 60 * 1000, 36 | pollStep: 5 * 1000 37 | }, window.CONVERSATE || {}); 38 | 39 | // Internal constants 40 | var // jQuery animation interval limits 41 | FX_INTERVAL_MIN = 250, 42 | FX_INTERVAL_MAX = 1000, 43 | 44 | // Minimum scrollbar height, so it's still visible on long convs 45 | MIN_SCROLLBAR_HEIGHT = 10, 46 | 47 | // Maximum number of lines to update the time on 48 | // Avoids crashing the browser when there is a long archive 49 | UPDATE_TIME_MAXLINES = 50 50 | ; 51 | 52 | // Detect DOM elements 53 | var $window = $(window), 54 | $body = $('body'), 55 | $conv = $('.cnv_messages'), 56 | $content = $conv.parent(), 57 | $input = $('.cnv_input'), 58 | $input_form = $input.find('form'), 59 | $message = $input_form.find('textarea[name="content"]'), 60 | $file = $input_form.find('input[name="file"]'), 61 | $fileToggle = $input_form.find('#cnv_input__toggler-file'), 62 | $users = $('.cnv_users'), 63 | $convTable = $conv.find('tr').parent(), 64 | $convTableCon = $('.cnv_table_con') 65 | ; 66 | 67 | // Prep content regex 68 | var urlPattern = new RegExp( 69 | '\\b(' 70 | + 'https?://' // http:// or https:// 71 | + '(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\\.)+(?:[A-Z]{2,6}\\.?|[A-Z0-9-]{2,}\\.?)|' // domain... 72 | + '[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?|' // ...or network name 73 | + '\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|' // ...or ipv4 74 | + '\\[?[A-F0-9]*:[A-F0-9:]+\\]?)' // ...or ipv6 75 | + '(?::\\d+)?' // optional port 76 | // everything in up to but excluding the trailing / 77 | + '(?:[/?]\\S*[^\\s`!()\\[\\]{};:\'".,<>?]|/)?' // URL which doesn't end in punctuation 78 | + ')', 79 | 80 | 'gi' 81 | ); 82 | var imgPattern = new RegExp('\\[\\[\\s*(?:img|image):([^\\]]+)\\s*\\]\\]') 83 | 84 | 85 | function escapeHtml(str) { 86 | return str 87 | .replace(/&/g, "&") 88 | .replace(//g, ">") 90 | .replace(/"/g, """) 91 | .replace(/'/g, "'") 92 | ; 93 | } 94 | 95 | /************************************************************************** 96 | *********************************************************** Classes 97 | **************************************************************************/ 98 | 99 | 100 | /** Poll manager 101 | */ 102 | function PollManager(room) { 103 | this.room = room; 104 | } 105 | PollManager.prototype = $.extend(PollManager.prototype, { 106 | interval: settings.pollMin, 107 | active: false, 108 | timeout: null, 109 | 110 | start: function () { 111 | /** Start the timer for the next poll */ 112 | var thisPoll = this; 113 | 114 | // Clear old timer 115 | if (this.timeout) { 116 | clearTimeout(this.timeout); 117 | } 118 | 119 | // Now polling 120 | this.active = true; 121 | this.timeout = setTimeout( 122 | function () { 123 | thisPoll.timeout = null; 124 | thisPoll.poll(); 125 | }, 126 | this.interval 127 | ); 128 | this.room.status.wait(this.interval); 129 | 130 | // Increase the polling interval 131 | if (this.interval < settings.pollMax) { 132 | this.interval += settings.pollStep; 133 | } 134 | }, 135 | stop: function () { 136 | /** Stop polling 137 | Used when making a request 138 | */ 139 | this.active = false; 140 | if (this.timeout) { 141 | clearTimeout(this.timeout); 142 | this.timeout = null; 143 | } 144 | this.room.status.stop(); 145 | }, 146 | poll: function () { 147 | this.stop(); 148 | this.room.status.loading(); 149 | this.room.check(); 150 | }, 151 | activity: function () { 152 | /** Call when activity has occurred 153 | Resets the interval to the minimum 154 | */ 155 | this.interval = settings.pollMin; 156 | } 157 | }); 158 | 159 | 160 | /** Status manager 161 | Track idle state and window focus 162 | Manage action and poll indicators 163 | */ 164 | function Status(room) { 165 | var thisStatus = this; 166 | this.room = room; 167 | this.idleSince = 0; 168 | this.unseenActivity = false; 169 | this.hasFocus = true; 170 | 171 | // Monitor idleness 172 | this.resetIdle(); 173 | $body 174 | .mousemove(function () { thisStatus.resetIdle(); }) 175 | .keypress(function () { thisStatus.resetIdle(); }) 176 | ; 177 | 178 | // Monitor window focus state 179 | $window 180 | .focus(function () { 181 | thisStatus.hasFocus = true; 182 | thisStatus._restore(); 183 | }) 184 | .blur(function () { 185 | thisStatus.hasFocus = false; 186 | }) 187 | ; 188 | 189 | if (settings.alertEnabled) { 190 | // Set up interval to watch for idleness (10 seconds) 191 | // If message arrives while idle, will alert immediately 192 | // However, we also need to watch in case if message arrives 193 | // before the idle timer runs out 194 | setInterval(function () { 195 | if (thisStatus.unseenActivity && thisStatus.isIdle()) { 196 | // Idle and messages to see. Alert. 197 | thisStatus.alert(); 198 | } 199 | }, 10 * 1000); 200 | } 201 | } 202 | Status.prototype = $.extend(Status.prototype, { 203 | // Title flasher 204 | flasher: null, 205 | $timer: null, 206 | $note: null, 207 | 208 | render: function ($el) { 209 | /** Render status display elements */ 210 | // Add timer elements 211 | var $con = $('
') 212 | .appendTo($el) 213 | ; 214 | this.$hourglass = $('
') 215 | .appendTo($con) 216 | ; 217 | this.$note = $('
') 218 | .appendTo($con) 219 | ; 220 | 221 | // Handle double clicks on hourglass 222 | var thisStatus = this, 223 | DOUBLE_CLICK_TOLERANCE = 500, 224 | lastClick = 0 225 | ; 226 | $con.click(function (e) { 227 | e.preventDefault(); 228 | 229 | var now = (new Date()).getTime(); 230 | if (now - lastClick > DOUBLE_CLICK_TOLERANCE) { 231 | lastClick = now; 232 | return; 233 | } 234 | 235 | thisStatus.$note.text('Reloading...'); 236 | if (thisStatus.room.poll.active) { 237 | // Reset poll interval, then jump to the next poll 238 | thisStatus.room.poll.activity(); 239 | thisStatus.room.poll.poll(); 240 | } else { 241 | // Assume something has failed - reload the page 242 | window.location.reload(); 243 | } 244 | }); 245 | }, 246 | 247 | resetIdle: function () { 248 | this.idleSince = (new Date()).getTime(); 249 | this.unseenActivity = false; 250 | }, 251 | isIdle: function () { 252 | var now = (new Date()).getTime(); 253 | return (now - this.idleSince > settings.idleAt); 254 | }, 255 | activity: function () { 256 | // If focus, cannot alert 257 | if (this.hasFocus) { 258 | return; 259 | } 260 | 261 | // Start flashing if is idle 262 | if (this.isIdle()) { 263 | this.alert(); 264 | } else { 265 | // Otherwise tell idle handler that there are messages pending 266 | this.unseenActivity = true; 267 | } 268 | }, 269 | loading: function () { 270 | this.$note.text('Loading...'); 271 | }, 272 | sending: function () { 273 | this.$note.text('Sending...'); 274 | }, 275 | stop: function () { 276 | this.$hourglass.stop(true).animate({ width: 0 }, 0); 277 | }, 278 | wait: function (time) { 279 | // Clear the timer note 280 | this.$note.text(''); 281 | 282 | // Calculate jQuery animation speed 283 | var fx_interval = ( 284 | FX_INTERVAL_MAX - FX_INTERVAL_MIN 285 | ) * ( 286 | this.room.poll.interval / settings.pollMax 287 | ); 288 | fx_interval += FX_INTERVAL_MIN; 289 | $.fx.interval = parseInt(fx_interval, 10); 290 | 291 | // Show the timer 292 | this.$hourglass 293 | .animate({ width: 0 }, 0) 294 | .animate({ width: '100%' }, this.room.poll.interval, 'linear') 295 | ; 296 | 297 | }, 298 | alert: function () { 299 | // Alert the user that something has happened 300 | if (!settings.alertEnabled) { 301 | return; 302 | } 303 | 304 | // If there's already a flasher, let that do its thing 305 | if (this.flasher) { 306 | return; 307 | } 308 | this._flash(); 309 | 310 | // Send notification 311 | new Notification('Chat activity') 312 | }, 313 | 314 | _flash: function () { 315 | var thisStatus = this, 316 | interval = 500 317 | ; 318 | 319 | // If we've regained focus (or had it already), and we're not idle, 320 | // make sure the title is reset, then end flasher 321 | if (this.hasFocus && !this.isIdle()) { 322 | this._restore(); 323 | return; 324 | } 325 | 326 | // Flash the title 327 | document.title = '**********'; 328 | $conv.css('background-color', '#ccc'); 329 | 330 | // Set up the timer to restore and continue the flashing 331 | this.flasher = setTimeout(function () { 332 | thisStatus._restore(); 333 | 334 | // If we don't have focus or are still idle, we'll need to flash it 335 | if (!this.hasFocus || this.isIdle()) { 336 | thisStatus.flasher = setTimeout(function () { 337 | thisStatus._flash(); 338 | }, interval); 339 | } 340 | 341 | }, interval); 342 | }, 343 | _restore: function () { 344 | // Restore the title 345 | document.title = 'Talk'; 346 | $conv.css('background-color', '#fff'); 347 | 348 | // If we're flashing, clear the flasher 349 | if (this.flasher) { 350 | clearTimeout(this.flasher); 351 | this.flasher = undefined; 352 | } 353 | } 354 | }); 355 | 356 | 357 | /** Room 358 | Manages messages 359 | Controls helper classes 360 | */ 361 | function Room() { 362 | var thisRoom = this; 363 | 364 | // Instantiate helpers 365 | this.status = new Status(this); 366 | this.poll = new PollManager(this); 367 | this.input = new Input(this); 368 | 369 | // Detect the CSRF middleware token 370 | this.csrfToken = $input_form.find('input[name="csrfmiddlewaretoken"]').val(); 371 | 372 | // Pull list of users 373 | var $userThs = $users.find('th'); 374 | this.users = {}; 375 | for (var i = 0; i < $userThs.length; i++) { 376 | this.users[$($userThs[i]).text()] = $($userThs[i]); 377 | } 378 | 379 | // Find and categorise time cells, and set up intervals 380 | this.timeCells = { 0: [], 1: [], 2: [], 3: [], 4: [] }; 381 | var $cell, cellTime, interval, now = time(); 382 | $convTable.find('td:first-child').each(function () { 383 | $cell = $(this); 384 | cellTime = parseInt($cell.attr('data-time'), 10); 385 | // If time is in the past, check once a minute 386 | thisRoom.timeCells[unitTimeRelative(cellTime, now) || 2].push( 387 | [$cell, cellTime] 388 | ); 389 | }); 390 | 391 | setInterval(function () { thisRoom.updateTimes(1); }, 0.5 * 1000); 392 | setInterval(function () { thisRoom.updateTimes(2); }, 30 * 1000); 393 | setInterval(function () { thisRoom.updateTimes(3); }, 60 * 30 * 1000); 394 | setInterval(function () { thisRoom.updateTimes(4); }, 60 * 60 * 12 * 1000); 395 | 396 | // Render content 397 | $convTable.find('td:last-child').each(function () { 398 | $cell = $(this); 399 | $cell.html(thisRoom._render($cell.html())); 400 | }); 401 | 402 | // Replace first line with controller 403 | if (settings.remaining) { 404 | this.$history = $('') 405 | .click(function (e) { 406 | e.preventDefault(); 407 | thisRoom.fetchHistory(); 408 | }) 409 | ; 410 | this.$firstRow = $convTable.find('tr:first-child'); 411 | this.$firstRow.find('td:last-child') 412 | .html(this.$history) 413 | ; 414 | this._fetchHistory({ 415 | first: settings.first, 416 | remaining: settings.remaining, 417 | messages: [] 418 | }); 419 | } 420 | 421 | // Scroll down to bottom 422 | this._scrollToBottom(); 423 | 424 | // Listen for resize 425 | const observer = new ResizeObserver((entries, observer) => { 426 | for (const entry of entries) { 427 | this._scrollToBottom(); 428 | } 429 | }); 430 | observer.observe($conv.get(0)); 431 | 432 | $convTable.click(e => { 433 | $message.focus(); 434 | }); 435 | 436 | // Request browser notification permission 437 | Notification.requestPermission(); 438 | 439 | // Set everything going 440 | this.poll.start(); 441 | } 442 | Room.prototype = $.extend(Room.prototype, { 443 | // PK of last message 444 | last: settings.last, 445 | 446 | // Difference in time between server and client 447 | timeDiff: 0, 448 | 449 | // Track time cells 450 | // { interval: [ [$cell, time], [$cell, time] ... ] ... } 451 | timeCells: null, 452 | 453 | // Request queue 454 | _requesting: false, 455 | _requestQueue: [], 456 | 457 | fetchHistory: function () { 458 | var thisRoom = this; 459 | $.ajax({ 460 | cache: false, 461 | type: 'POST', 462 | url: settings.apiHistory, 463 | data: { 464 | 'first': this.first, 465 | 'csrfmiddlewaretoken': this.csrfToken 466 | }, 467 | dataType: 'json', 468 | success: function (data) { 469 | thisRoom._fetchHistory(data); 470 | }, 471 | error: function (jqXHR, textStatus, errorThrown) { 472 | thisRoom.poll.start(); 473 | thisRoom.error(errorThrown); 474 | } 475 | }); 476 | }, 477 | _fetchHistory: function (data) { 478 | this.first = data.first; 479 | this.remaining = data.remaining; 480 | 481 | var i, m, $newLine; 482 | for (i = 0; i < data.messages.length; i++) { 483 | m = data.messages[i]; 484 | $newLine = this._mkLine(m.msgTime, m.user, m.content) 485 | .insertAfter(this.$firstRow) 486 | ; 487 | this.timeCells[unitTimeRelative(m.msgTime)].push( 488 | [$newLine.find('.cnv_time'), m.msgTime] 489 | ); 490 | } 491 | 492 | if (this.remaining === 0) { 493 | this.$history.parent().text('No older messages'); 494 | } else { 495 | this.$history.text( 496 | this.remaining + ' older message' + ( 497 | this.remaining == 1 ? '' : 's' 498 | ) 499 | ); 500 | } 501 | }, 502 | 503 | check: function () { 504 | this._request(settings.apiCheck, new FormData()); 505 | }, 506 | send: function (formData) { 507 | this._request(settings.apiSend, formData); 508 | }, 509 | error: function (msg) { 510 | this._add(time(), 'ERROR', msg + '
Reload'); 511 | }, 512 | _request: function (url, data) { 513 | // Queue requests 514 | if (this._requesting) { 515 | this._requestQueue.push([url, data]); 516 | return; 517 | } 518 | this._requesting = true; 519 | 520 | // Make request 521 | var thisRoom = this; 522 | data.append('last', this.last || 0); 523 | data.append('csrfmiddlewaretoken', this.csrfToken); 524 | data.append('hasFocus', this.status.hasFocus); 525 | $.ajax({ 526 | cache: false, 527 | type: 'POST', 528 | url: url, 529 | data: data, 530 | dataType: 'json', 531 | processData: false, 532 | contentType: false, 533 | success: function (data) { 534 | thisRoom._response(data); 535 | }, 536 | error: function (jqXHR, textStatus, errorThrown) { 537 | thisRoom.poll.start(); 538 | thisRoom.error(errorThrown); 539 | } 540 | }); 541 | }, 542 | _response: function (data) { 543 | // Catch error 544 | if (!data.success) { 545 | this.error(data.error); 546 | this.poll.start(); 547 | return; 548 | } 549 | 550 | // Store data 551 | this.last = data.last; 552 | this.setTimeDiff(data.time); 553 | 554 | // Add messages 555 | for (var i = 0, l = data.messages.length; i < l; i++) { 556 | var message = data.messages[i]; 557 | this._add(message.time, message.user, message.content); 558 | } 559 | 560 | // Check for activity 561 | if (data.messages.length > 0) { 562 | this.poll.activity(); 563 | } 564 | 565 | // Update user data 566 | this._updateUsers(data.users); 567 | 568 | // See if there's anything on the request queue 569 | this._requesting = false; 570 | if (this._requestQueue.length > 0) { 571 | this._request.apply(this, this._requestQueue.shift()); 572 | return; 573 | } 574 | 575 | // Start new poll 576 | this.poll.start(); 577 | }, 578 | _mkLine: function (msgTime, user, content) { 579 | // Add new line and register time cell 580 | return $( 581 | '' 582 | + '' 583 | + '' + user + '' 584 | + '' + this._render(content) + '' 585 | + '' 586 | ); 587 | }, 588 | _add: function (msgTime, user, content) { 589 | var $newLine = this._mkLine(msgTime, user, content) 590 | .appendTo($convTable) 591 | ; 592 | this.timeCells[unitTimeRelative(msgTime)].push( 593 | [$newLine.find('.cnv_time'), msgTime] 594 | ); 595 | 596 | // Scroll down to bottom 597 | this._scrollToBottom(); 598 | 599 | // Tell the status manager that activity has occurred 600 | this.status.activity(); 601 | }, 602 | _scrollToBottom: function () { 603 | const el = $conv.get(0); 604 | el.scrollTop = el.scrollHeight; 605 | }, 606 | _render: function (content) { 607 | /** Render content */ 608 | return content; 609 | }, 610 | _updateUsers: function (users) { 611 | var $el, name, found = {}, del = $.extend({}, this.users); 612 | for (var i = 0, l = users.length; i < l; i++) { 613 | // Find the user 614 | name = users[i].username; 615 | $el = this.users[name]; 616 | if ($el) { 617 | // Found 618 | found[name] = $el; 619 | delete (del[name]); 620 | } else { 621 | // Does not exist yet 622 | // They don't exist, add in position 623 | var $row = $(''); 624 | $el = $('' + name + '').appendTo($row); 625 | $row 626 | .append($('')) 627 | .appendTo($users) 628 | ; 629 | found[name] = $el; 630 | } 631 | 632 | // Update and pop from del 633 | $el.parent().attr( 634 | 'class', 635 | 'cnv_user_' + name + ' ' 636 | + ( 637 | users[i].active ? ( 638 | users[i].has_focus ? 'cnv_active' : 'cnv_afk' 639 | ) : 'cnv_inactive' 640 | ) 641 | ); 642 | $el.next() 643 | .text(humanTime(users[i].last_spoke)) 644 | .attr('title', 'Last ping: ' + humanTime(users[i].last_seen)) 645 | ; 646 | } 647 | 648 | // Delete any deleted 649 | for (i = 0; i < del.length; i++) { 650 | del[i].remove(); 651 | } 652 | this.users = found; 653 | }, 654 | 655 | setTimeDiff: function (serverTime) { 656 | this.timeDiff = time() - serverTime; 657 | }, 658 | updateTimes: function (unit) { 659 | // Don't need to update the seconds if they're idle 660 | if (unit < 2 && this.status.isIdle()) { 661 | return; 662 | } 663 | var cells = this.timeCells[unit], 664 | newCells = [], 665 | length = cells.length, 666 | start = 0, 667 | now = time() - this.timeDiff, 668 | i, cell, timeDelta, newUnit 669 | ; 670 | 671 | for (i = 0; i < length; i++) { 672 | // Update text 673 | cell = cells[i]; 674 | timeDelta = now - cell[1]; 675 | newUnit = unitTime(timeDelta); 676 | cell[0].text(humanTime(timeDelta, newUnit)); 677 | 678 | // If the unit has changed, move to different timeCell group 679 | if (unit < 4 && unit != newUnit) { 680 | this.timeCells[unit + 1].push(cell); 681 | } else { 682 | newCells.push(cell); 683 | } 684 | } 685 | this.timeCells[unit] = newCells; 686 | } 687 | }); 688 | 689 | 690 | /** Input handler 691 | */ 692 | function Input(room) { 693 | var thisInput = this; 694 | this.room = room; 695 | 696 | // Remove the username and move the input button 697 | var $inputTable = $input.find('table'), 698 | $col1 = $($inputTable.find('td').get(0)), 699 | $col2 = $($inputTable.find('td').get(1)) 700 | ; 701 | $col2.html($col1.html()); 702 | $col1.empty(); 703 | 704 | // Make the input button autogrow 705 | autosize($message); 706 | 707 | // Override enter keypress 708 | $message.keyup( 709 | event => { 710 | // Enter without shift, submit 711 | if (event.keyCode == 13 && !event.shiftKey) { 712 | $input_form.submit(); 713 | } 714 | } 715 | ).keydown( 716 | event => { 717 | // Suppress enter without shift to avoid growing the field 718 | if (event.keyCode == 13 && !event.shiftKey) { 719 | event.preventDefault(); 720 | } 721 | } 722 | ); 723 | 724 | // Override form submit 725 | $input_form.submit(function () { 726 | return thisInput.submit(); 727 | }); 728 | 729 | // Tell the status indicator it can set up 730 | this.room.status.render($col1); 731 | } 732 | Input.prototype = $.extend(Input.prototype, { 733 | submit: function () { 734 | // Check there's data 735 | if (!$message.val()) { 736 | // ++ TODO: feedback 737 | return false; 738 | } 739 | 740 | // Get message from form 741 | var formData = new FormData($input_form[0]); 742 | 743 | // Reset form 744 | $message.val(''); 745 | $file.val(''); 746 | $fileToggle.prop('checked', false); 747 | autosize.update($message); 748 | $message.focus(); 749 | 750 | // Send message 751 | this.room.poll.stop(); 752 | this.room.status.sending(); 753 | this.room.send(formData); 754 | 755 | // Forbid normal submission 756 | return false; 757 | } 758 | }); 759 | 760 | 761 | /************************************************************************** 762 | *********************************************************** Functions 763 | **************************************************************************/ 764 | 765 | function time() { 766 | return Math.round(new Date().getTime() / 1000); 767 | } 768 | 769 | function unitTimeRelative(from, to) { 770 | if (!to) { 771 | to = time(); 772 | } 773 | return unitTime(to - from); 774 | } 775 | 776 | function unitTime(delta) { 777 | /** Return an integer representing the time delta 778 | 0 Less than 0 779 | 1 Seconds 780 | 2 Minutes 781 | 3 Hours 782 | 4 Days 783 | */ 784 | if (delta < 0) { 785 | return 0; 786 | } else if (delta < 60) { 787 | return 1; 788 | } else if (delta < 60 * 60) { 789 | return 2; 790 | } else if (delta < 60 * 60 * 24) { 791 | return 3; 792 | } else { 793 | return 4; 794 | } 795 | } 796 | 797 | var TIME_UNITS = ['never', 'second', 'minute', 'hour', 'day'], 798 | TIME_DENOM = [1, 1, 60, 60 * 60, 60 * 60 * 24] 799 | ; 800 | function humanTime(delta, unit) { 801 | var term, val; 802 | if (delta < 0) { 803 | return TIME_UNITS[0]; 804 | } 805 | if (!unit) { 806 | unit = unitTime(delta); 807 | } 808 | 809 | val = Math.floor(delta / TIME_DENOM[unit]); 810 | return '' + val + ' ' + TIME_UNITS[unit] + (val == 1 ? '' : 's'); 811 | } 812 | 813 | 814 | /************************************************************************** 815 | *********************************************************** Activate 816 | **************************************************************************/ 817 | 818 | // Check for elements 819 | function testEl(name, $el) { 820 | if (!$el.length) { 821 | var err = alert; 822 | if (window.console) { 823 | err = console.log; 824 | } 825 | err('Could not find required element for ' + name); 826 | return false; 827 | } 828 | return true; 829 | } 830 | if ( 831 | !testEl('input container', $input) || 832 | !testEl('input form', $input_form) || 833 | !testEl('input field', $message) || 834 | !testEl('content container', $content) || 835 | !testEl('message container', $conv) || 836 | !testEl('message table', $convTable) 837 | ) { 838 | // Required element not found - abort 839 | return; 840 | } 841 | 842 | // Create the room, activating polling etc 843 | room = new Room(); 844 | room.setTimeDiff(settings.serverTime); 845 | }); 846 | 847 | --------------------------------------------------------------------------------