Browse Source

WIP notifications

Rafał Pitoń 10 years ago
parent
commit
6f6a32f989
32 changed files with 617 additions and 30 deletions
  1. 7 2
      misago/conf/defaults.py
  2. 1 0
      misago/notifications/__init__.py
  3. 39 0
      misago/notifications/api.py
  4. 7 0
      misago/notifications/apps.py
  5. 0 0
      misago/notifications/middleware.py
  6. 34 0
      misago/notifications/migrations/0001_initial.py
  7. 0 0
      misago/notifications/migrations/__init__.py
  8. 21 0
      misago/notifications/models.py
  9. 8 0
      misago/notifications/urls.py
  10. 18 0
      misago/notifications/utils.py
  11. 71 0
      misago/notifications/views.py
  12. 5 1
      misago/static/misago/css/misago/dropdowns.less
  13. 55 0
      misago/static/misago/css/misago/loader.less
  14. 2 0
      misago/static/misago/css/misago/misago.less
  15. 21 7
      misago/static/misago/css/misago/navbar.less
  16. 61 0
      misago/static/misago/css/misago/notifications.less
  17. 11 1
      misago/static/misago/css/misago/variables.less
  18. 1 2
      misago/static/misago/js/misago-ajax.js
  19. 1 1
      misago/static/misago/js/misago-alerts.js
  20. 35 0
      misago/static/misago/js/misago-dom.js
  21. 63 0
      misago/static/misago/js/misago-events.js
  22. 47 0
      misago/static/misago/js/misago-notifications.js
  23. 14 10
      misago/static/misago/js/misago-timestamps.js
  24. 6 4
      misago/static/misago/js/misago-tooltips.js
  25. 8 0
      misago/static/misago/js/tinycon.min.js
  26. 4 0
      misago/templates/misago/base.html
  27. 47 0
      misago/templates/misago/notifications/dropdown.html
  28. 1 0
      misago/templates/misago/notifications/full.html
  29. 26 0
      misago/templates/misago/user_nav.html
  30. 1 0
      misago/urls.py
  31. 1 1
      misago/users/migrations/0001_initial.py
  32. 1 1
      misago/users/models/user.py

+ 7 - 2
misago/conf/defaults.py

@@ -55,11 +55,15 @@ PIPELINE_JS = {
             'misago/js/jquery.js',
             'misago/js/jquery.js',
             'misago/js/bootstrap.js',
             'misago/js/bootstrap.js',
             'misago/js/moment.min.js',
             'misago/js/moment.min.js',
-            'misago/js/misago-alerts.js',
-            'misago/js/misago-ajax.js',
+            'misago/js/tinycon.min.js',
+            'misago/js/misago-dom.js',
             'misago/js/misago-timestamps.js',
             'misago/js/misago-timestamps.js',
             'misago/js/misago-tooltips.js',
             'misago/js/misago-tooltips.js',
             'misago/js/misago-yesnoswitch.js',
             'misago/js/misago-yesnoswitch.js',
+            'misago/js/misago-alerts.js',
+            'misago/js/misago-ajax.js',
+            'misago/js/misago-events.js',
+            'misago/js/misago-notifications.js',
         ),
         ),
         'output_filename': 'misago.js',
         'output_filename': 'misago.js',
     },
     },
@@ -118,6 +122,7 @@ INSTALLED_APPS = (
     'misago.conf',
     'misago.conf',
     'misago.markup',
     'misago.markup',
     'misago.forums',
     'misago.forums',
+    'misago.notifications',
     'misago.legal',
     'misago.legal',
     'misago.faker',
     'misago.faker',
 )
 )

+ 1 - 0
misago/notifications/__init__.py

@@ -0,0 +1 @@
+from misago.notifications.api import *  # noqa

+ 39 - 0
misago/notifications/api.py

@@ -0,0 +1,39 @@
+from django.db.models import F
+from django.db.transaction import atomic
+from django.utils.html import escape
+
+from misago.notifications.models import Notification
+
+
+__all__ = ['notify_user']
+
+
+def notify_user(user, message, url, trigger, formats=None, sender=None):
+    message_escaped = escape(message)
+    if formats:
+        final_formats = {}
+        for format, replace in formats.items():
+            final_formats[format] = '<strong>%s</strong>' % escape(replace)
+        message_escaped = message_escaped % final_formats
+
+    new_notification = Notification(user=user,
+                                    trigger=trigger,
+                                    url=url,
+                                    message=message_escaped)
+
+    if sender:
+        new_notification.sender = sender
+        new_notification.sender_username = sender.username
+        new_notification.sender_slug = sender.slug
+
+    new_notification.save()
+    user.new_notifications = F('new_notifications') + 1
+    user.save(update_fields=['new_notifications'])
+
+
+#def read_user_notifications(user, trigger):
+#    pass
+
+
+#def read_all_user_alerts(user):
+#    pass

+ 7 - 0
misago/notifications/apps.py

@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class MisagoNotificationsConfig(AppConfig):
+    name = 'misago.notifications'
+    label = 'misago_notifications'
+    verbose_name = "Misago Notifications"

+ 0 - 0
misago/notifications/middleware.py


+ 34 - 0
misago/notifications/migrations/0001_initial.py

@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import django.utils.timezone
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Notification',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('is_new', models.BooleanField(default=True)),
+                ('date', models.DateTimeField(default=django.utils.timezone.now, db_index=True)),
+                ('trigger', models.CharField(max_length=8)),
+                ('message', models.TextField()),
+                ('url', models.TextField()),
+                ('sender_username', models.CharField(max_length=255, blank=True, null=True)),
+                ('sender_slug', models.CharField(max_length=255, blank=True, null=True)),
+                ('sender', models.ForeignKey(on_delete=django.db.models.deletion.SET_NULL, blank=True, to=settings.AUTH_USER_MODEL, null=True)),
+                ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+            },
+            bases=(models.Model,),
+        ),
+    ]

+ 0 - 0
misago/notifications/migrations/__init__.py


+ 21 - 0
misago/notifications/models.py

@@ -0,0 +1,21 @@
+import cgi
+
+from django.conf import settings
+from django.db import models
+from django.utils import timezone
+
+
+class Notification(models.Model):
+    user = models.ForeignKey(settings.AUTH_USER_MODEL,
+                             related_name='notifications')
+    is_new = models.BooleanField(default=True)
+    date = models.DateTimeField(default=timezone.now, db_index=True)
+    trigger = models.CharField(max_length=8)
+    message = models.TextField()
+    url = models.TextField()
+    sender = models.ForeignKey(settings.AUTH_USER_MODEL,
+                           on_delete=models.SET_NULL,
+                           related_name='notifications_by',
+                           blank=True, null=True)
+    sender_username = models.CharField(max_length=255, blank=True, null=True)
+    sender_slug = models.CharField(max_length=255, blank=True, null=True)

+ 8 - 0
misago/notifications/urls.py

@@ -0,0 +1,8 @@
+from django.conf.urls import include, patterns, url
+
+
+urlpatterns = patterns('misago.notifications.views',
+    url(r'^notifications/$', 'notifications', name='notifications'),
+    url(r'^notifications/event-sender/$', 'event_sender', name='notifications_event_sender'),
+    url(r'^notifications/new/$', 'new_notification', name='new_notification'),
+)

+ 18 - 0
misago/notifications/utils.py

@@ -0,0 +1,18 @@
+from hashlib import md5
+
+
+def target_trigger(message, obj_id=None):
+    hash_seed = [message]
+
+    if obj_pk:
+      hash_seed.append(unicode(obj_id))
+
+    return md5(hash_seed.join('+')).hexdigest[:8]
+
+
+def variables_dict(plain=None, links=None, users=None, threads=None):
+    final_variables = {}
+
+    final_variables.update(plain)
+
+    return final_variables

+ 71 - 0
misago/notifications/views.py

@@ -0,0 +1,71 @@
+from django.http import JsonResponse
+from django.shortcuts import render
+from django.utils.translation import ugettext as _, ungettext
+
+from misago.core.decorators import ajax_only
+
+from misago.users.decorators import deny_guests
+
+
+@deny_guests
+def notifications(request):
+    if request.is_ajax():
+        return dropdown(request)
+    else:
+        return full_page(request)
+
+
+def dropdown(request):
+    template = render(request, 'misago/notifications/dropdown.html', {
+        'items': request.user.notifications.order_by('-id').iterator(),
+        'notifications_count': request.user.notifications.count(),
+    })
+
+    return JsonResponse({
+        'is_error': False,
+        'count': request.user.new_notifications,
+        'html': template.content,
+    })
+
+
+def full_page(request):
+    return render(request, 'misago/notifications/full.html')
+
+
+@ajax_only
+@deny_guests
+def event_sender(request):
+    if request.user.new_notifications:
+        message = ungettext("You have %(notifications)s new notification",
+                            "You have %(notifications)s new notifications",
+                            request.user.new_notifications)
+        message = message % {'notifications': request.user.new_notifications}
+    else:
+        message = _("Your notifications")
+
+    return JsonResponse({
+        'is_error': False,
+        'count': request.user.new_notifications,
+        'message': message,
+    })
+
+
+@deny_guests
+def new_notification(request):
+    from django.contrib.auth import get_user_model
+    from faker import Factory
+    faker = Factory.create()
+
+    sender = get_user_model().objects.order_by('?')[:1][0]
+
+    from misago.notifications import notify_user
+    notify_user(
+        request.user,
+        _("Replied to %(thread)s"),
+        '/',
+        'test',
+        formats={'thread': 'LoremIpsum'},
+        sender=sender,)
+
+    from django.http import HttpResponse
+    return HttpResponse('Notification set.')

+ 5 - 1
misago/static/misago/css/misago/dropdowns.less

@@ -128,12 +128,16 @@
     }
     }
 
 
     .dropdown-title {
     .dropdown-title {
-      background-color: #ecf0f1;
+      background-color: @dropdown-title-bg;
+      border-bottom: 1px solid @dropdown-title-border;
       border-radius: @border-radius-base @border-radius-base 0px 0px;
       border-radius: @border-radius-base @border-radius-base 0px 0px;
       margin-top: -5px;
       margin-top: -5px;
       margin-bottom: 5px;
       margin-bottom: 5px;
       padding: @padding-base-vertical @padding-base-horizontal;
       padding: @padding-base-vertical @padding-base-horizontal;
 
 
+      color: @dropdown-title-color;
+      font-weight: bold;
+
       .badge {
       .badge {
         background-color: darken(@brand-danger, 10%);
         background-color: darken(@brand-danger, 10%);
         border-radius: @border-radius-small;
         border-radius: @border-radius-small;

+ 55 - 0
misago/static/misago/css/misago/loader.less

@@ -0,0 +1,55 @@
+//
+// Ajax loader
+// --------------------------------------------------
+
+
+.loader {
+  margin: @line-height-computed auto;
+  width: auto;
+  text-align: center;
+
+  p {
+    margin-top: @line-height-computed / 2;
+    color: @loader-text-color;
+    font-size: @font-size-small;
+  }
+
+  &>div {
+    width: 18px;
+    height: 18px;
+    background-color: @loader-color;
+
+    border-radius: 100%;
+    display: inline-block;
+    -webkit-animation: bouncedelay 1.4s infinite ease-in-out;
+    animation: bouncedelay 1.4s infinite ease-in-out;
+    /* Prevent first frame from flickering when animation starts */
+    -webkit-animation-fill-mode: both;
+    animation-fill-mode: both;
+  }
+
+  .bounce1 {
+    -webkit-animation-delay: -0.32s;
+    animation-delay: -0.32s;
+  }
+
+  .bounce2 {
+    -webkit-animation-delay: -0.16s;
+    animation-delay: -0.16s;
+  }
+}
+
+@-webkit-keyframes bouncedelay {
+  0%, 80%, 100% { -webkit-transform: scale(0.0) }
+  40% { -webkit-transform: scale(1.0) }
+}
+
+@keyframes bouncedelay {
+  0%, 80%, 100% {
+    transform: scale(0.0);
+    -webkit-transform: scale(0.0);
+  } 40% {
+    transform: scale(1.0);
+    -webkit-transform: scale(1.0);
+  }
+}

+ 2 - 0
misago/static/misago/css/misago/misago.less

@@ -4,6 +4,7 @@
 @import "dropdowns.less";
 @import "dropdowns.less";
 @import "editor.less";
 @import "editor.less";
 @import "inputs.less";
 @import "inputs.less";
+@import "loader.less";
 @import "navs.less";
 @import "navs.less";
 @import "modals.less";
 @import "modals.less";
 @import "markup.less";
 @import "markup.less";
@@ -21,6 +22,7 @@
 @import "footer.less";
 @import "footer.less";
 
 
 @import "forms.less";
 @import "forms.less";
+@import "notifications.less";
 @import "userslists.less";
 @import "userslists.less";
 
 
 // Pages
 // Pages

+ 21 - 7
misago/static/misago/css/misago/navbar.less

@@ -44,15 +44,16 @@
   .navbar-nav-primary {
   .navbar-nav-primary {
     .navbar-vertical-align(@navbar-icon-size * 1.7 - 2px);
     .navbar-vertical-align(@navbar-icon-size * 1.7 - 2px);
     margin-bottom: 0px;
     margin-bottom: 0px;
-    padding-right: @navbar-padding-horizontal;
+    margin-left: @navbar-icon-size + @navbar-padding-horizontal;
+    margin-right: @navbar-icon-size;
 
 
     &>li {
     &>li {
       margin: 0px;
       margin: 0px;
-      margin-left: @navbar-padding-horizontal / 2;
       padding: 0px;
       padding: 0px;
 
 
       &>a {
       &>a {
         /* Make button blocky */
         /* Make button blocky */
+        clear: both;
         border: 1px solid @navbar-icon-link-border;
         border: 1px solid @navbar-icon-link-border;
         border-radius: @border-radius-base;
         border-radius: @border-radius-base;
         margin: 0px;
         margin: 0px;
@@ -78,9 +79,16 @@
         }
         }
 
 
         &>.badge {
         &>.badge {
+          border-radius: @border-radius-small;
           position: absolute;
           position: absolute;
-          right: -6px;
-          top: -4px;
+          right: 1px;
+          bottom: 1px;
+          padding: 1px 3px;
+          padding-left: 4px;
+          padding-top: 2px;
+
+          font-size: @font-size-base * 0.8;
+          font-weight: normal;
         }
         }
 
 
         /* Primary styles */
         /* Primary styles */
@@ -110,10 +118,17 @@
       &.open {
       &.open {
         &>a {
         &>a {
           &:link, &:active, &:visited, &:hover {
           &:link, &:active, &:visited, &:hover {
-            background-color: @navbar-icon-link-active-bg !important;
+            background-color: @navbar-icon-link-open-bg !important;
+            border-color: @navbar-icon-link-open-border !important;
             box-shadow: none;
             box-shadow: none;
 
 
-            color: @navbar-icon-link-active-color !important;
+            color: @navbar-icon-link-open-color !important;
+
+            &>.badge {
+              background-color: @navbar-icon-link-open-color;
+
+              color: @navbar-icon-link-open-border;
+            }
           }
           }
         }
         }
       }
       }
@@ -173,7 +188,6 @@
   /* Put some distance between user menu and primary nav */
   /* Put some distance between user menu and primary nav */
   .navbar-nav-user {
   .navbar-nav-user {
     padding-right: @navbar-padding-horizontal;
     padding-right: @navbar-padding-horizontal;
-    margin-left: @navbar-padding-horizontal * 2;
   }
   }
 
 
   /* Change way switch and dropdown appears */
   /* Change way switch and dropdown appears */

+ 61 - 0
misago/static/misago/css/misago/notifications.less

@@ -0,0 +1,61 @@
+//
+// Notifications
+// --------------------------------------------------
+
+
+// Notifications dropdowned list
+.user-notifications-nav {
+  .dropdown-menu {
+    width: 280px;
+
+    .dropdown-title {
+      margin-bottom: 0px;
+    }
+
+    ul {
+      margin: 0px;
+      max-height: @line-height-computed * 8.75;
+      overflow: auto;
+
+      li {
+        border-bottom: 1px solid @dropdown-divider-bg;
+        padding: @padding-base-vertical @padding-base-horizontal;
+
+        a {
+          display: block;
+          margin: 0px;
+          padding: 0px;
+
+          color: @text-color;
+
+          &:hover {
+            background: none;
+
+            text-decoration: none;
+          }
+
+          &:active {
+            background: none;
+
+            color: @state-clicked;
+          }
+        }
+
+        footer {
+          font-size: @font-size-small;
+
+          a {
+            display: inline;
+          }
+        }
+
+        &.empty {
+          border-bottom: none;
+          padding: @padding-large-vertical @padding-base-horizontal;
+
+          text-align: center;
+        }
+      }
+    }
+  }
+}

+ 11 - 1
misago/static/misago/css/misago/variables.less

@@ -54,6 +54,8 @@
 @component-active-bg:         @brand-secondary;
 @component-active-bg:         @brand-secondary;
 @component-active-color:      #fff;
 @component-active-color:      #fff;
 
 
+@loader-color:                @brand-accent;
+@loader-text-color:           @text-muted;
 
 
 //== Navbar
 //== Navbar
 //
 //
@@ -84,6 +86,10 @@
 @navbar-icon-link-active-border:        darken(@navbar-default-border, 2%);
 @navbar-icon-link-active-border:        darken(@navbar-default-border, 2%);
 @navbar-icon-link-active-color:         @state-clicked;
 @navbar-icon-link-active-color:         @state-clicked;
 
 
+@navbar-icon-link-open-bg:              @state-active;
+@navbar-icon-link-open-border:          @state-active;
+@navbar-icon-link-open-color:           #fff;
+
 
 
 // Only User dropdown switch uses those colors
 // Only User dropdown switch uses those colors
 @navbar-user-name-color:                @gray;
 @navbar-user-name-color:                @gray;
@@ -127,6 +133,11 @@
 //
 //
 //## Misago dropdowns flavor
 //## Misago dropdowns flavor
 
 
+// Dropdown title
+@dropdown-title-color:           @text-color;
+@dropdown-title-bg:              @navbar-default-bg;
+@dropdown-title-border:          darken(@navbar-default-border, 5%);
+
 // Dropdown link text color.
 // Dropdown link text color.
 @dropdown-link-color:            @text-color;
 @dropdown-link-color:            @text-color;
 
 
@@ -136,7 +147,6 @@
 @dropdown-link-active-color:     #fff;
 @dropdown-link-active-color:     #fff;
 @dropdown-link-active-bg:        @state-clicked;
 @dropdown-link-active-bg:        @state-clicked;
 
 
-
 // Open dropdown shadow color.
 // Open dropdown shadow color.
 @dropdown-shadow:                fadeOut(#333, 50%);
 @dropdown-shadow:                fadeOut(#333, 50%);
 
 

+ 1 - 2
misago/static/misago/js/misago-ajax.js

@@ -1,7 +1,6 @@
 // Misago ajax error handler
 // Misago ajax error handler
 $(function() {
 $(function() {
   function handleAjaxError(event, jqxhr, settings, thrownError) {
   function handleAjaxError(event, jqxhr, settings, thrownError) {
-    console.log(jqxhr);
     if (jqxhr.responseJSON) {
     if (jqxhr.responseJSON) {
       var error_message = jqxhr.responseJSON.message
       var error_message = jqxhr.responseJSON.message
     } else if (thrownError == "NOT FOUND") {
     } else if (thrownError == "NOT FOUND") {
@@ -11,7 +10,7 @@ $(function() {
     } else {
     } else {
       var error_message = ajax_errors.generic;
       var error_message = ajax_errors.generic;
     }
     }
-    console.log(thrownError);
+
     if (thrownError != "") {
     if (thrownError != "") {
       $.misago_alerts().error(error_message);
       $.misago_alerts().error(error_message);
     }
     }

+ 1 - 1
misago/static/misago/js/misago-alerts.js

@@ -77,7 +77,7 @@
 
 
   $.misago_alerts = function(options) {
   $.misago_alerts = function(options) {
     if ($._misago_alerts == undefined) {
     if ($._misago_alerts == undefined) {
-      $._misago_alerts = MisagoAlerts(options)
+      $._misago_alerts = MisagoAlerts(options);
     }
     }
     return $._misago_alerts;
     return $._misago_alerts;
   };
   };

+ 35 - 0
misago/static/misago/js/misago-dom.js

@@ -0,0 +1,35 @@
+// Misago DOM upades helper
+(function($) {
+
+  // Events sender
+  // ===============================
+
+  var MisagoDOM = function() {
+
+    this.dom_listeners = [];
+    this.change = function(callback) {
+      this.dom_listeners.push(callback);
+      callback();
+    };
+
+    this.changed = function() {
+      $.each(this.dom_listeners, function(i, callback) {
+        callback();
+      });
+    };
+
+    // Return object
+    return this;
+  };
+
+  // Plugin definition
+  // ==========================
+
+  $.misago_dom = function() {
+    if ($._misago_dom == undefined) {
+      $._misago_dom = MisagoDOM();
+    }
+    return $._misago_dom;
+  };
+
+}(jQuery));

+ 63 - 0
misago/static/misago/js/misago-events.js

@@ -0,0 +1,63 @@
+// Misago events extension
+(function($) {
+
+  // Events handler class definition
+  // ===============================
+
+  var MisagoEvents = function(frequency) {
+
+    if (frequency == undefined) {
+      this.frequency = 15000;
+    } else {
+      this.frequency = frequency;
+    }
+
+    this.listeners = [];
+    this.listener = function(url, callback) {
+      this.listeners.push({url: url, callback: callback});
+      this.listen();
+    };
+
+    this.listen = function() {
+      var ajax_calls = [];
+      $.each(this.listeners, function(i, obj) {
+        ajax_calls.push($.get(obj.url));
+      });
+
+      var listeners = this.listeners;
+      $.when.apply($, ajax_calls).then(function () {
+          var _args = arguments;
+          var events_count = 0;
+
+          if (listeners.length == 1) {
+            var data = _args[0];
+            events_count = data.count;
+            listeners[0].callback(data);
+          } else {
+            $.each(listeners, function(i, obj) {
+              var data = _args[i][0];
+              events_count += data.count;
+              obj.callback(data);
+            });
+          }
+          Tinycon.setBubble(events_count);
+      });
+    };
+
+    window.setInterval(this.listen, this.frequency);
+
+    // Return object
+    return this;
+  };
+
+  // Plugin definition
+  // ==========================
+
+  $.misago_events = function(frequency) {
+    if ($._misago_events == undefined) {
+      $._misago_events = MisagoEvents(frequency);
+    }
+    return $._misago_events;
+  };
+
+}(jQuery));

+ 47 - 0
misago/static/misago/js/misago-notifications.js

@@ -0,0 +1,47 @@
+$(function() {
+  var ajax_cache = null;
+
+  var $container = $('.user-notifications-nav');
+  var $link = $container.children('a');
+
+  function notifications_handler(data) {
+    var $badge = $link.children('.badge');
+
+    if (data.count > 0) {
+      if ($badge.length == 0) {
+        $badge = $('<span class="badge">' + data.count + '</span>');
+        $badge.hide();
+        $link.append($badge);
+        $badge.fadeIn();
+      } else {
+        $badge.text(data.count);
+      }
+    } else if ($badge.length > 0) {
+        $badge.fadeOut();
+    }
+    $link.attr("title", data.message);
+    $link.tooltip('fixTitle');
+
+    if (ajax_cache != null && data.count != ajax_cache.count) {
+      ajax_cache = null
+    }
+  }
+
+  if (typeof notifications_url !== "undefined") {
+    $.misago_events().listener(notifications_url, notifications_handler);
+  }
+
+  var $display = $container.find('.display');
+  var $loader = $container.find('.loader');
+
+  $container.on('show.bs.dropdown', function () {
+    if (ajax_cache == null) {
+      $.get($link.attr('href'), function(data) {
+        ajax_cache = data;
+        $loader.hide();
+        $display.html(data.html);
+        $.misago_dom().changed();
+      });
+    }
+  })
+});

+ 14 - 10
misago/static/misago/js/misago-timestamps.js

@@ -6,17 +6,20 @@ $(function() {
   var moments = {};
   var moments = {};
 
 
   // Initialize moment.js for dates
   // Initialize moment.js for dates
-  $('.dynamic').each(function() {
-    var timestamp = $(this).data('timestamp');
-    var rel_moment = moment(timestamp);
+  function discover_dates() {
+    $('.dynamic').each(function() {
+      var timestamp = $(this).data('timestamp');
+      var rel_moment = moment(timestamp);
 
 
-    if (moment_now.diff(rel_moment, 'days') <= 7) {
-      moments[timestamp] = {
-        rel_moment: rel_moment,
-        original: $(this).text()
+      if (moment_now.diff(rel_moment, 'days') <= 7) {
+        moments[timestamp] = {
+          rel_moment: rel_moment,
+          original: $(this).text()
+        }
       }
       }
-    }
-  });
+    });
+  }
+  discover_dates();
 
 
   // Update function
   // Update function
   function update_times() {
   function update_times() {
@@ -37,6 +40,7 @@ $(function() {
   }
   }
 
 
   // Run updates
   // Run updates
-  update_times();
+  $.misago_dom().change(discover_dates);
+  $.misago_dom().change(update_times);
   window.setInterval(update_times, 5000);
   window.setInterval(update_times, 5000);
 });
 });

+ 6 - 4
misago/static/misago/js/misago-tooltips.js

@@ -1,7 +1,9 @@
 // Register tooltips
 // Register tooltips
 $(function() {
 $(function() {
-  $('.tooltip-top').tooltip({placement: 'top', container: 'body'});
-  $('.tooltip-bottom').tooltip({placement: 'bottom', container: 'body'});
-  $('.tooltip-left').tooltip({placement: 'left', container: 'body'});
-  $('.tooltip-right').tooltip({placement: 'right', container: 'body'});
+  $.misago_dom().change(function() {
+    $('.tooltip-top').tooltip({placement: 'top', container: 'body'});
+    $('.tooltip-bottom').tooltip({placement: 'bottom', container: 'body'});
+    $('.tooltip-left').tooltip({placement: 'left', container: 'body'});
+    $('.tooltip-right').tooltip({placement: 'right', container: 'body'});
+  });
 });
 });

+ 8 - 0
misago/static/misago/js/tinycon.min.js

@@ -0,0 +1,8 @@
+/*!
+  Tinycon - A small library for manipulating the Favicon
+  Tom Moor, http://tommoor.com
+  Copyright (c) 2012 Tom Moor
+  @license MIT Licensed
+  @version 0.6.3
+*/
+(function(){var Tinycon={};var currentFavicon=null;var originalFavicon=null;var faviconImage=null;var canvas=null;var options={};var r=window.devicePixelRatio||1;var size=16*r;var defaults={width:7,height:9,font:10*r+'px arial',colour:'#ffffff',background:'#F03D25',fallback:true,crossOrigin:true,abbreviate:true};var ua=(function(){var agent=navigator.userAgent.toLowerCase();return function(browser){return agent.indexOf(browser)!==-1}}());var browser={ie:ua('msie'),chrome:ua('chrome'),webkit:ua('chrome')||ua('safari'),safari:ua('safari')&&!ua('chrome'),mozilla:ua('mozilla')&&!ua('chrome')&&!ua('safari')};var getFaviconTag=function(){var links=document.getElementsByTagName('link');for(var i=0,len=links.length;i<len;i++){if((links[i].getAttribute('rel')||'').match(/\bicon\b/)){return links[i]}}return false};var removeFaviconTag=function(){var links=document.getElementsByTagName('link');var head=document.getElementsByTagName('head')[0];for(var i=0,len=links.length;i<len;i++){var exists=(typeof(links[i])!=='undefined');if(exists&&(links[i].getAttribute('rel')||'').match(/\bicon\b/)){head.removeChild(links[i])}}};var getCurrentFavicon=function(){if(!originalFavicon||!currentFavicon){var tag=getFaviconTag();originalFavicon=currentFavicon=tag?tag.getAttribute('href'):'/favicon.ico'}return currentFavicon};var getCanvas=function(){if(!canvas){canvas=document.createElement("canvas");canvas.width=size;canvas.height=size}return canvas};var setFaviconTag=function(url){removeFaviconTag();var link=document.createElement('link');link.type='image/x-icon';link.rel='icon';link.href=url;document.getElementsByTagName('head')[0].appendChild(link)};var log=function(message){if(window.console)window.console.log(message)};var drawFavicon=function(label,colour){if(!getCanvas().getContext||browser.ie||browser.safari||options.fallback==='force'){return updateTitle(label)}var context=getCanvas().getContext("2d");var colour=colour||'#000000';var src=getCurrentFavicon();faviconImage=document.createElement('img');faviconImage.onload=function(){context.clearRect(0,0,size,size);context.drawImage(faviconImage,0,0,faviconImage.width,faviconImage.height,0,0,size,size);if((label+'').length>0)drawBubble(context,label,colour);refreshFavicon()};if(!src.match(/^data/)&&options.crossOrigin){faviconImage.crossOrigin='anonymous'}faviconImage.src=src};var updateTitle=function(label){if(options.fallback){var originalTitle=document.title;if(originalTitle[0]==='('){originalTitle=originalTitle.slice(originalTitle.indexOf(' '))}if((label+'').length>0){document.title='('+label+') '+originalTitle}else{document.title=originalTitle}}};var drawBubble=function(context,label,colour){if(typeof label=='number'&&label>99&&options.abbreviate){label=abbreviateNumber(label)}var len=(label+'').length-1;var width=options.width*r+(6*r*len),height=options.height*r;var top=size-height,left=size-width-r,bottom=16*r,right=16*r,radius=2*r;context.font=(browser.webkit?'bold ':'')+options.font;context.fillStyle=options.background;context.strokeStyle=options.background;context.lineWidth=r;context.beginPath();context.moveTo(left+radius,top);context.quadraticCurveTo(left,top,left,top+radius);context.lineTo(left,bottom-radius);context.quadraticCurveTo(left,bottom,left+radius,bottom);context.lineTo(right-radius,bottom);context.quadraticCurveTo(right,bottom,right,bottom-radius);context.lineTo(right,top+radius);context.quadraticCurveTo(right,top,right-radius,top);context.closePath();context.fill();context.beginPath();context.strokeStyle="rgba(0,0,0,0.3)";context.moveTo(left+radius/2.0,bottom);context.lineTo(right-radius/2.0,bottom);context.stroke();context.fillStyle=options.colour;context.textAlign="right";context.textBaseline="top";context.fillText(label,r===2?29:15,browser.mozilla?7*r:6*r)};var refreshFavicon=function(){if(!getCanvas().getContext)return;setFaviconTag(getCanvas().toDataURL())};var abbreviateNumber=function(label){var metricPrefixes=[['G',1000000000],['M',1000000],['k',1000]];for(var i=0;i<metricPrefixes.length;++i){if(label>=metricPrefixes[i][1]){label=round(label/metricPrefixes[i][1])+metricPrefixes[i][0];break}}return label};var round=function(value,precision){var number=new Number(value);return number.toFixed(precision)};Tinycon.setOptions=function(custom){options={};for(var key in defaults){options[key]=custom.hasOwnProperty(key)?custom[key]:defaults[key]}return this};Tinycon.setImage=function(url){currentFavicon=url;refreshFavicon();return this};Tinycon.setBubble=function(label,colour){label=label||'';drawFavicon(label,colour);return this};Tinycon.reset=function(){setFaviconTag(originalFavicon)};Tinycon.setOptions(defaults);window.Tinycon=Tinycon;if(typeof define==='function'&&define.amd){define(Tinycon)}})();

+ 4 - 0
misago/templates/misago/base.html

@@ -38,6 +38,9 @@
         not_found: "{% trans "API link is invalid." %}",
         not_found: "{% trans "API link is invalid." %}",
         timeout: "{% trans "Request has timed out." %}"
         timeout: "{% trans "Request has timed out." %}"
       };
       };
+      {% if user.is_authenticated %}
+      var notifications_url = "{% url 'misago:notifications_event_sender' %}";
+      {% endif %}
     </script>
     </script>
     {% compressed_js 'misago' %}
     {% compressed_js 'misago' %}
     <script lang="JavaScript">
     <script lang="JavaScript">
@@ -48,6 +51,7 @@
           info_template: "<div class=\"alert-div\"><p class=\"alert alert-info\"><span class=\"alert-icon fa fa-info-circle\"></span>%message% <button type=\"button\" class=\"close\">{% trans "Ok!" %}</button></p></div>",
           info_template: "<div class=\"alert-div\"><p class=\"alert alert-info\"><span class=\"alert-icon fa fa-info-circle\"></span>%message% <button type=\"button\" class=\"close\">{% trans "Ok!" %}</button></p></div>",
           success_template: "<div class=\"alert-div\"><p class=\"alert alert-success\"><span class=\"alert-icon fa fa-check-circle\"></span>%message% <button type=\"button\" class=\"close\">{% trans "Ok!" %}</button></p></div>"
           success_template: "<div class=\"alert-div\"><p class=\"alert alert-success\"><span class=\"alert-icon fa fa-check-circle\"></span>%message% <button type=\"button\" class=\"close\">{% trans "Ok!" %}</button></p></div>"
         });
         });
+        $.misago_events();
       });
       });
     </script>
     </script>
     {% block javascripts %}{% endblock javascripts %}
     {% block javascripts %}{% endblock javascripts %}

+ 47 - 0
misago/templates/misago/notifications/dropdown.html

@@ -0,0 +1,47 @@
+{% load i18n misago_avatars misago_capture %}
+<div class="dropdown-title">
+  {% if user.new_notifications %}
+    {% capture trimmed as notifications %}
+    <span class="label label-danger">{{ user.new_notifications }}</span>
+    {% endcapture %}
+    {% blocktrans trimmed with notifications=notifications|safe count counter=user.new_notifications%}
+    {{ notifications }} new notification
+    {% plural %}
+    {{ notifications }} new notifications
+    {% endblocktrans %}
+  {% else %}
+    {% capture trimmed as notifications %}
+    <span class="label label-default">{{ notifications_count }}</span>
+    {% endcapture %}
+    {% blocktrans trimmed with notifications=notifications|safe count counter=user.new_notifications%}
+    {{ notifications }} notification
+    {% plural %}
+    {{ notifications }} notifications
+    {% endblocktrans %}
+  {% endif %}
+</div>
+<ul class="list-unstyled">
+  {% for item in items %}
+  <li{% if item.is_new %} class="new"{% endif %}>
+    <a href="{{ item.url }}">
+      {{ item.message|safe }}
+    </a>
+    <footer class="text-muted">
+      {% if item.sender_username %}
+        {% if item.sender_id %}
+        <a href="{% url USER_PROFILE_URL user_slug=item.sender_slug user_id=item.sender_id %}" class="item-title">{{ item.sender_username }}</a>
+        {% else %}
+        <strong class="item-title">{{ item.sender_username }}</strong>
+        {% endif %}
+      {% endif %}
+      <abbr class="tooltip-top dynamic time-ago" title="{{ item.date }}" data-timestamp="{{ item.date|date:"c" }}">
+        {{ item.date|date }}
+      </abbr>
+    </footer>
+  </li>
+  {% empty %}
+  <li class="empty">
+    {% trans "You don't have any notifications." %}
+  </li>
+  {% endfor %}
+</ul>

+ 1 - 0
misago/templates/misago/notifications/full.html

@@ -0,0 +1 @@
+FULL ALERTS LIST!

+ 26 - 0
misago/templates/misago/user_nav.html

@@ -46,3 +46,29 @@
     </ul>
     </ul>
   </li>
   </li>
 </ul>
 </ul>
+
+<ul class="nav navbar-nav navbar-nav-primary navbar-right">
+  <li class="user-notifications-nav dropdown">
+    <a href="{% url 'misago:notifications' %}" class="dropdown-toggle tooltip-bottom"
+        {% if user.new_notifications %}
+        title="{% blocktrans with notifications=user.new_notifications count counter=user.new_notifications %}{{ notifications }} new notification{% plural %}{{ notifications }} new notifications{% endblocktrans %}"
+        {% else %}
+        title="{% trans "Your notifications" %}"
+        {% endif %}
+        data-toggle="dropdown">
+      <span class="fa fa-bell-o fa-fw"></span>
+      {% if user.new_notifications %}
+      <span class="badge">{{ user.new_notifications }}</span>
+      {% endif %}
+    </a>
+    <div class="dropdown-menu">
+      <div class="display"></div>
+      <div class="loader">
+        <div class="bounce1"></div>
+        <div class="bounce2"></div>
+        <div class="bounce3"></div>
+        <p>{% trans "Loading..." %}</p>
+      </div>
+    </div>
+  </li>
+</ul>

+ 1 - 0
misago/urls.py

@@ -14,6 +14,7 @@ urlpatterns = patterns('misago.core.views',
 urlpatterns += patterns('',
 urlpatterns += patterns('',
     url(r'^', include('misago.legal.urls')),
     url(r'^', include('misago.legal.urls')),
     url(r'^', include('misago.users.urls')),
     url(r'^', include('misago.users.urls')),
+    url(r'^', include('misago.notifications.urls')),
 )
 )
 
 
 
 

+ 1 - 1
misago/users/migrations/0001_initial.py

@@ -54,7 +54,7 @@ class Migration(migrations.Migration):
                 ('warning_level_update_on', models.DateTimeField(null=True, blank=True)),
                 ('warning_level_update_on', models.DateTimeField(null=True, blank=True)),
                 ('following', models.PositiveIntegerField(default=0)),
                 ('following', models.PositiveIntegerField(default=0)),
                 ('followers', models.PositiveIntegerField(default=0)),
                 ('followers', models.PositiveIntegerField(default=0)),
-                ('new_alerts', models.PositiveIntegerField(default=0)),
+                ('new_notifications', models.PositiveIntegerField(default=0)),
                 ('limit_private_thread_invites', models.PositiveIntegerField(default=0)),
                 ('limit_private_thread_invites', models.PositiveIntegerField(default=0)),
                 ('unread_private_threads', models.PositiveIntegerField(default=0)),
                 ('unread_private_threads', models.PositiveIntegerField(default=0)),
                 ('sync_unred_private_threads', models.BooleanField(default=False)),
                 ('sync_unred_private_threads', models.BooleanField(default=False)),

+ 1 - 1
misago/users/models/user.py

@@ -201,7 +201,7 @@ class User(AbstractBaseUser, PermissionsMixin):
     blocks = models.ManyToManyField(
     blocks = models.ManyToManyField(
         'self', related_name='blocked_by', symmetrical=False)
         'self', related_name='blocked_by', symmetrical=False)
 
 
-    new_alerts = models.PositiveIntegerField(default=0)
+    new_notifications = models.PositiveIntegerField(default=0)
 
 
     limit_private_thread_invites = models.PositiveIntegerField(default=0)
     limit_private_thread_invites = models.PositiveIntegerField(default=0)
     unread_private_threads = models.PositiveIntegerField(default=0)
     unread_private_threads = models.PositiveIntegerField(default=0)