├── .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 | {% if remaining == 0 %}No older messages{% else %}Older messages hidden{% endif %} |
10 |
11 |
12 | {% for message in room_messages %}
13 |
14 | | {{ message.timestamp|naturaltimestamp }} |
15 | {{ message.user.username }} |
16 | {{ message.render|safe }} |
17 |
18 | {% endfor %}
19 |
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 |
6 |
24 | |
25 |
26 |
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 | | {{ user.username }} | {{ user.last_spoke|naturaltimedelta }} |
7 | {% endfor %}
8 |
9 |
10 | Settings
11 |
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 |
--------------------------------------------------------------------------------