changeset 425:76ba9478ebbd

Initial beta-test commit of a revamped, jquery ui tab-based PM system. This is for #211.
author Brian Neal <bgneal@gmail.com>
date Tue, 26 Apr 2011 00:16:35 +0000
parents 8df6e9edac22
children c8148cf11a79
files gpp/messages/models.py gpp/messages/static/css/messages.css gpp/messages/static/js/tabbed_messages.js gpp/messages/urls.py gpp/messages/views2.py gpp/settings.py gpp/templates/messages/compose_tab.html gpp/templates/messages/inbox_tab.html gpp/templates/messages/options_tab.html gpp/templates/messages/outbox_tab.html gpp/templates/messages/pagination.html gpp/templates/messages/tabbed_base.html gpp/templates/messages/trash_tab.html
diffstat 13 files changed, 720 insertions(+), 7 deletions(-) [+]
line wrap: on
line diff
--- a/gpp/messages/models.py	Sun Apr 24 03:54:40 2011 +0000
+++ b/gpp/messages/models.py	Tue Apr 26 00:16:35 2011 +0000
@@ -14,17 +14,17 @@
 
     def inbox(self, user):
         return self.filter(receiver=user,
-            receiver_delete_date__isnull=True)
+            receiver_delete_date__isnull=True).select_related('sender')
 
     def outbox(self, user):
         return self.filter(sender=user,
-            sender_delete_date__isnull=True)
+            sender_delete_date__isnull=True).select_related('receiver')
 
     def trash(self, user):
         return self.filter(
             Q(sender=user, sender_delete_date__isnull=False) |
             Q(receiver=user, receiver_delete_date__isnull=False)
-        )
+        ).select_related()
 
     def unread_count(self, user):
         return self.filter(receiver=user, read_date__isnull=True).count()
--- a/gpp/messages/static/css/messages.css	Sun Apr 24 03:54:40 2011 +0000
+++ b/gpp/messages/static/css/messages.css	Tue Apr 26 00:16:35 2011 +0000
@@ -11,7 +11,8 @@
    border: 1px solid black;
    border-spacing: 0px;
    border-collapse: collapse;
-   margin-left: 1em;
+   margin: 1em auto;
+   width: 96%;
 }
 
 table.messages th {
@@ -64,3 +65,12 @@
 form.messages-button {
    display: inline;
 }
+.unread {
+   font-weight: bold;
+}
+.replied_to {
+   font-style: italic;
+}
+.pagination {
+   text-align: right;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/messages/static/js/tabbed_messages.js	Tue Apr 26 00:16:35 2011 +0000
@@ -0,0 +1,165 @@
+$(document).ready(function() {
+   $tabs = $('#tabs').tabs({
+      selected: initialTab,
+      select: function(event, ui) {
+         $(ui.panel).html('');
+      },
+      load: function(event, ui) {
+         selectedTab = ui;
+         if (doReply && ui.index == 1)
+         {
+            doReply = false;
+            var msg = msgCache[$msgDialog.msgId];
+            $('#id_receiver').val(msg.sender);
+            $('#id_subject').val(msg.re_subject);
+            $('#id_message').val(msg.re_content);
+         }
+      },
+      ajaxOptions: {
+         error: function(xhr, status, index, anchor) {
+            $(anchor.hash).html(
+               "Oops, we couldn't load this tab. We'll try to fix this as soon as possible.");
+         }
+      }
+   });
+   $msgDialog = $('#msgDialog').dialog({
+      autoOpen: false,
+      width: 460,
+      buttons: [
+         { 
+            text: "Reply", 
+            click: function() { 
+               doReply = true;
+               $(this).dialog('close'); 
+               $tabs.tabs("select", 1);
+            } 
+         },
+         { 
+            text: "Close",
+            click: function() { 
+               $(this).dialog('close'); 
+            } 
+         }
+      ]
+   });
+});
+
+var $tabs = 0;
+var $msgDialog = 0;
+var msgCache = {};
+var doReply = false;
+var selectedTab = 0;
+
+function showMsg(link, id) {
+   $msgDialog.msgId = id;  // create a msgId attribute on the dialog
+   var msg = msgCache[id];
+
+   // mark as read if necessary
+   if (username == msg.receiver) {
+      $(link).removeClass('unread');
+   }
+
+   $msgDialog.html(msg.content);
+   var title = 'PM From ' + msg.sender + ' To ' + msg.receiver + '<br /> ' + msg.subject;
+   $msgDialog.dialog('option', 'title', title);
+   $msgDialog.dialog('open');
+}
+
+function msgShow(link, id) {
+   if (msgCache[id]) {
+      showMsg(link, id);
+      return;
+   }
+   $.ajax({
+      url: '/messages/beta/message/',
+      type: 'POST',
+      data: {
+         msg_id : id
+      },
+      dataType: 'json',
+      success: function (data, textStatus) {
+         msgCache[id] = data;
+         showMsg(link, id);
+      },
+      error: function (xhr, textStatus, ex) {
+         alert('Oops, an error occurred. ' + xhr.statusText + ' - ' + 
+            xhr.responseText);
+      }
+   });
+}
+
+function submitOptions(form) {
+   $.ajax({
+      url: '/messages/beta/options-tab/',
+      type: 'POST',
+      data: $(form).serialize(),
+      dataType: 'html',
+      success: function (data, textStatus) {
+         $(selectedTab.panel).html(data);
+      },
+      error: function (xhr, textStatus, ex) {
+         alert('Oops, an error occurred. ' + xhr.statusText + ' - ' + 
+            xhr.responseText);
+      }
+   });
+   return false;
+}
+
+function messageSubmit(form) {
+   $.ajax({
+      url: '/messages/beta/compose-tab/',
+      type: 'POST',
+      data: $(form).serialize(),
+      dataType: 'html',
+      success: function (data, textStatus) {
+         $('#ui-tabs-1').html(data);
+      },
+      error: function (xhr, textStatus, ex) {
+         alert('Oops, an error occurred. ' + xhr.statusText + ' - ' + 
+            xhr.responseText);
+      }
+   });
+   return false;
+}
+
+function tabMasterCheckClick(box, name) {
+   var state = $(box).attr('checked');
+   $('input[name="' + name + '"]').each(function() {
+      this.checked = state;
+   });
+}
+
+function bulkMsgAction(form, action) {
+   if (confirm("Really " + action + " checked messages?")) {
+      $.ajax({
+         url: '/messages/beta/bulk/',
+         type: 'POST',
+         data: $(form).serialize(),
+         dataType: 'text',
+         success: function (data, textStatus) {
+            $tabs.tabs("load", selectedTab.index);
+         },
+         error: function (xhr, textStatus, ex) {
+            alert('Oops, an error occurred. ' + xhr.statusText + ' - ' + 
+               xhr.responseText);
+         }
+      });
+   }
+   return false;
+}
+
+function ajaxPageFetch(link) {
+   $.ajax({
+      url: link.href,
+      type: 'GET',
+      dataType: 'html',
+      success: function (data, textStatus) {
+         $(selectedTab.panel).html(data);
+      },
+      error: function (xhr, textStatus, ex) {
+         alert('Oops, an error occurred. ' + xhr.statusText + ' - ' + 
+            xhr.responseText);
+      }
+   });
+   return false;
+}
--- a/gpp/messages/urls.py	Sun Apr 24 03:54:40 2011 +0000
+++ b/gpp/messages/urls.py	Tue Apr 26 00:16:35 2011 +0000
@@ -2,6 +2,7 @@
 from django.conf.urls.defaults import *
 from django.views.generic import RedirectView
 
+
 urlpatterns = patterns('messages.views',
     url(r'^inbox/$', 'inbox', name='messages-inbox'),
     url(r'^outbox/$', 'outbox', name='messages-outbox'),
@@ -21,3 +22,35 @@
     (r'^$', RedirectView.as_view(url='inbox/')),
 )
 
+urlpatterns += patterns('messages.views2',
+    url(r'^beta/$',
+        'index',
+        name='messages-beta_index'),
+    url(r'^beta/(inbox|compose|outbox|trash|options)/$',
+        'index',
+        name='messages-beta_index_named'),
+    url(r'^beta/inbox-tab/$',
+        'inbox',
+        name='messages-beta_inbox'),
+    url(r'^beta/outbox-tab/$',
+        'outbox',
+        name='messages-beta_outbox'),
+    url(r'^beta/trash-tab/$',
+        'trash',
+        name='messages-beta_trash'),
+    url(r'^beta/message/$',
+        'message',
+        name='messages-beta_message'),
+    url(r'^beta/options-tab/$',
+        'options',
+        name='messages-beta_options'),
+    url(r'^beta/compose-tab/$',
+        'compose',
+        name='messages-beta_compose'),
+    url(r'^beta/compose-tab/([\w.@+-]{1,30})/$',
+        'compose',
+        name='messages-beta_compose_to'),
+    url(r'^beta/bulk/$',
+        'bulk',
+        name='messages-beta_bulk'),
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/messages/views2.py	Tue Apr 26 00:16:35 2011 +0000
@@ -0,0 +1,287 @@
+"""
+Views for the messages application.
+
+"""
+import datetime
+
+from django.contrib.auth.decorators import login_required
+from django.contrib import messages
+from django.core.paginator import Paginator, EmptyPage, InvalidPage
+from django.core.urlresolvers import reverse
+from django.http import HttpResponse
+from django.http import HttpResponseForbidden
+from django.http import HttpResponseNotAllowed
+from django.http import HttpResponseRedirect
+from django.shortcuts import get_object_or_404
+from django.shortcuts import render
+import django.utils.simplejson as json
+
+from messages.models import Message, Options
+from messages.forms import OptionsForm, ComposeForm
+from messages.utils import reply_subject, quote_message
+
+
+MSGS_PER_PAGE = 20
+
+TAB_INDICES = {
+    'inbox': 0,
+    'compose': 1,
+    'outbox': 2,
+    'trash': 3,
+    'options': 4,
+}
+
+
+def _get_page(request):
+    try:
+        n = int(request.GET.get('page', '1'))
+    except ValueError:
+        n = 1
+    return n
+
+
+@login_required
+def index(request, tab=None):
+    """
+    This function displays the base tabbed private messages view.
+
+    """
+    tab_index = TAB_INDICES[tab] if tab else 0
+    return render(request, 'messages/tabbed_base.html', {
+        'tab': tab_index,
+        })
+
+
+def inbox(request):
+    """
+    Returns the inbox for the user.
+
+    """
+    if not request.user.is_authenticated():
+        return HttpResponseForbidden()
+
+    msg_list = Message.objects.inbox(request.user)
+    paginator = Paginator(msg_list, MSGS_PER_PAGE)
+    try:
+        msgs = paginator.page(_get_page(request))
+    except EmptyPage, InvalidPage:
+        msgs = paginator.page(paginator.num_pages)
+
+    return render(request, 'messages/inbox_tab.html', {
+        'msgs': msgs,
+        'url': reverse('messages-beta_inbox'),
+        })
+
+
+def outbox(request):
+    """
+    Returns the outbox for the user.
+
+    """
+    if not request.user.is_authenticated():
+        return HttpResponseForbidden()
+
+    msg_list = Message.objects.outbox(request.user)
+    paginator = Paginator(msg_list, MSGS_PER_PAGE)
+    try:
+        msgs = paginator.page(_get_page(request))
+    except EmptyPage, InvalidPage:
+        msgs = paginator.page(paginator.num_pages)
+
+    return render(request, 'messages/outbox_tab.html', {
+        'msgs': msgs,
+        'url': reverse('messages-beta_outbox'),
+        })
+
+
+def trash(request):
+    """
+    Returns the trash for the user.
+
+    """
+    if not request.user.is_authenticated():
+        return HttpResponseForbidden()
+
+    msg_list = Message.objects.trash(request.user)
+    paginator = Paginator(msg_list, MSGS_PER_PAGE)
+    try:
+        msgs = paginator.page(_get_page(request))
+    except EmptyPage, InvalidPage:
+        msgs = paginator.page(paginator.num_pages)
+
+    return render(request, 'messages/trash_tab.html', {
+        'msgs': msgs,
+        'url': reverse('messages-beta_trash'),
+        })
+
+
+def message(request):
+    """
+    This view function retrieves a message and returns it as a JSON object.
+
+    """
+    if not request.user.is_authenticated():
+        return HttpResponseForbidden()
+    if request.method != 'POST':
+        return HttpResponseNotAllowed(['POST'])
+
+    msg_id = request.POST.get('msg_id')
+    msg = get_object_or_404(Message.objects.select_related(), pk=msg_id)
+    if msg.sender != request.user and msg.receiver != request.user:
+        return HttpResponseForbidden()
+
+    if msg.receiver == request.user and msg.read_date is None:
+        msg.read_date = datetime.datetime.now()
+        msg.save()
+
+    msg_dict = dict(subject=msg.subject,
+                    sender=msg.sender.username,
+                    receiver=msg.receiver.username,
+                    content=msg.html,
+                    re_subject=reply_subject(msg.subject),
+                    re_content=quote_message(msg.sender.username, msg.send_date,
+                                             msg.message))
+
+    result = json.dumps(msg_dict, ensure_ascii=False)
+    return HttpResponse(result, content_type='application/json')
+
+
+def options(request):
+    """
+    This view handles the displaying and changing of private message options.
+
+    """
+    if not request.user.is_authenticated():
+        return HttpResponseForbidden()
+
+    if request.method == "POST":
+        options = Options.objects.for_user(request.user)
+        form = OptionsForm(request.POST, instance=options, prefix='opts')
+        if form.is_valid():
+            form.save()
+            messages.success(request, 'Options saved.')
+    else:
+        options = Options.objects.for_user(request.user)
+        form = OptionsForm(instance=options, prefix='opts')
+
+    return render(request, 'messages/options_tab.html', {
+        'form': form,
+        })
+
+
+def compose(request, receiver=None):
+    """
+    Process or prepare the compose form to create a new private message.
+
+    """
+    if not request.user.is_authenticated():
+        return HttpResponseForbidden()
+
+    if request.method == "POST":
+        compose_form = ComposeForm(request.user, request.POST)
+        if compose_form.is_valid():
+            compose_form.save(sender=request.user)
+            messages.success(request, 'Message sent.')
+            return HttpResponseRedirect(reverse('messages-beta_index_named', args=['compose']))
+    else:
+        if receiver is not None:
+            form_data = {'receiver': receiver}
+            compose_form = ComposeForm(request.user, initial=form_data)
+        else:
+            compose_form = ComposeForm(request.user)
+
+    return render(request, 'messages/compose_tab.html', {
+        'compose_form': compose_form,
+        })
+
+
+def _only_integers(slist):
+    """
+    Accepts a list of strings. Returns a list of integers consisting of only
+    those elements from the original list that could be converted to integers
+
+    """
+    result = []
+    for s in slist:
+        try:
+            n = int(s)
+        except ValueError:
+            pass
+        else:
+            result.append(n)
+    return result
+
+
+def _delete_msgs(user, msg_ids):
+    """
+    Deletes the messages given by the list of msg_ids. For this to succeed, the
+    user has to be either the sender or receiver on each message.
+
+    """
+    msg_ids = _only_integers(msg_ids)
+    msgs = Message.objects.filter(id__in=msg_ids)
+
+    for msg in msgs:
+        if msg.sender == user:
+            if (msg.receiver_delete_date is not None or
+                    msg.read_date is None):
+                # Both parties deleted the message or receiver hasn't read it
+                # yet, we can delete it now
+                msg.delete()
+            else:
+                # receiver still has it in inbox
+                msg.sender_delete_date = datetime.datetime.now()
+                msg.save()
+
+        elif msg.receiver == user:
+            if msg.sender_delete_date is not None:
+                # both parties deleted the message, we can delete it now
+                msg.delete()
+            else:
+                # sender still has it in the outbox
+                msg.receiver_delete_date = datetime.datetime.now()
+                msg.save()
+
+
+def _undelete_msgs(user, msg_ids):
+    """
+    Attempts to "undelete" the messages given by the msg_ids list.
+    This will only succeed if the user is either the sender or receiver.
+
+    """
+    msg_ids = _only_integers(msg_ids)
+    msgs = Message.objects.filter(id__in=msg_ids)
+    for msg in msgs:
+        if msg.sender == user:
+            msg.sender_delete_date = None
+            msg.save()
+        elif msg.receiver == user:
+            msg.receiver_delete_date = None
+            msg.save()
+
+
+def bulk(request):
+    """
+    This view processes messages in bulk. Arrays of message ids are expected in
+    the POST query dict: inbox_ids and outbox_ids will be deleted; trash_ids will
+    be undeleted.
+
+    """
+    if not request.user.is_authenticated():
+        return HttpResponseForbidden()
+    if request.method != 'POST':
+        return HttpResponseNotAllowed(['POST'])
+
+    delete_ids = []
+    if 'inbox_ids' in request.POST:
+        delete_ids.extend(request.POST.getlist('inbox_ids'))
+    if 'outbox_ids' in request.POST:
+        delete_ids.extend(request.POST.getlist('outbox_ids'))
+
+    if len(delete_ids):
+        _delete_msgs(request.user, delete_ids)
+
+    if 'trash_ids' in request.POST:
+        _undelete_msgs(request.user, request.POST.getlist('trash_ids'))
+
+    return HttpResponse('');
--- a/gpp/settings.py	Sun Apr 24 03:54:40 2011 +0000
+++ b/gpp/settings.py	Tue Apr 26 00:16:35 2011 +0000
@@ -278,13 +278,13 @@
 # should also be used by developers when creating form media classes.
 GPP_THIRD_PARTY_JS = {
     'jquery': (
-        'http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js',
+        'http://ajax.googleapis.com/ajax/libs/jquery/1.5.2/jquery.min.js',
     ),
     'jquery-jeditable': (
         'js/jquery.jeditable.mini.js',
     ),
     'jquery-ui': (
-        'http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.5/jquery-ui.min.js',
+        'http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.12/jquery-ui.min.js',
     ),
     'markitup': (
         'js/markitup/jquery.markitup.pack.js',
@@ -297,7 +297,7 @@
 }
 GPP_THIRD_PARTY_CSS = {
     'jquery-ui': (
-        'http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.5/themes/redmond/jquery-ui.css',
+        'http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.12/themes/redmond/jquery-ui.css',
     ),
     'markitup': (
         'js/markitup/skins/markitup/style.css',
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/templates/messages/compose_tab.html	Tue Apr 26 00:16:35 2011 +0000
@@ -0,0 +1,23 @@
+{% load url from future %}
+{% load core_tags %}
+{% load script_tags %}
+{% script_tags 'markitup' %}
+{% if messages %}
+<ul class="user-messages">
+   {% for msg in messages %}
+      <li{% if msg.tags %} class="{{ msg.tags }}"{% endif %}>{{ msg }}</li>
+   {% endfor %}
+</ul>
+{% endif %}
+<form action="{% url 'messages-beta_compose' %}" method="post">{% csrf_token %}
+<table>
+{{ compose_form.as_table }}
+<tr>
+   <td>&nbsp;</td>
+   <td>
+      {% comment_dialogs %}
+      <input type="submit" name="submit_button" value="Send" />
+   </td>
+</tr>
+</table>
+</form>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/templates/messages/inbox_tab.html	Tue Apr 26 00:16:35 2011 +0000
@@ -0,0 +1,40 @@
+{% load url from future %}
+{% if messages %}
+<ul class="user-messages">
+   {% for msg in messages %}
+      <li{% if msg.tags %} class="{{ msg.tags }}"{% endif %}>{{ msg }}</li>
+   {% endfor %}
+</ul>
+{% endif %}
+{% if msgs.object_list %}
+   <form action="." onsubmit="return bulkMsgAction(this, 'delete');">
+   <table class="messages">
+   <tr>
+      <th>From</th>
+      <th>Subject</th>
+      <th>Date</th>
+      <th><input type="checkbox" onclick="tabMasterCheckClick(this, 'inbox_ids');" /></th>
+   </tr>
+   {% for msg in msgs.object_list %}
+   <tr>
+      <td><a href="{% url 'bio.views.view_profile' msg.sender.username %}">
+         {{ msg.sender.username }}</a></td>
+      <td>
+         <a href="#" onclick="msgShow(this, {{ msg.id }}); return false;"
+            class="{% if msg.unread %}unread {% endif %}{% if msg.replied_to %}replied_to{% endif %}">{{ msg.subject }}</a>
+         </td>
+      <td>{{ msg.send_date|date:"M j, Y g:i A" }}</td>
+      <td><input type="checkbox" name="inbox_ids" value="{{ msg.id }}" /></td>
+   </tr>
+   {% endfor %}
+   <tr><td colspan="4"><input type="submit" value="Delete Checked Messages" /></td></tr>
+   </table>
+   </form>
+   {% include "messages/pagination.html" %}
+   <ul>
+      <li>Messages in <strong>bold</strong> are unread.</li>
+      <li>Messages in <em>italics</em> have been replied to.</li>
+   </ul>
+{% else %}
+   <p><em>Your Inbox is empty.</em></p>
+{% endif %}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/templates/messages/options_tab.html	Tue Apr 26 00:16:35 2011 +0000
@@ -0,0 +1,18 @@
+{% if messages %}
+<ul id="message-opts-messages" class="user-messages">
+   {% for msg in messages %}
+      <li{% if msg.tags %} class="{{ msg.tags }}"{% endif %}>{{ msg }}</li>
+   {% endfor %}
+</ul>
+{% endif %}
+<form action="." method="post" onsubmit="return submitOptions(this);">{% csrf_token %}
+<table>
+{{ form.as_table }}
+<tr>
+   <td>&nbsp;</td>
+   <td>
+      <input type="submit" name="submit_button" value="Save" />
+   </td>
+</tr>
+</table>
+</form>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/templates/messages/outbox_tab.html	Tue Apr 26 00:16:35 2011 +0000
@@ -0,0 +1,42 @@
+{% load url from future %}
+{% if messages %}
+<ul class="user-messages">
+   {% for msg in messages %}
+      <li{% if msg.tags %} class="{{ msg.tags }}"{% endif %}>{{ msg }}</li>
+   {% endfor %}
+</ul>
+{% endif %}
+{% if msgs.object_list %}
+   <form action="." onsubmit="return bulkMsgAction(this, 'delete');">
+   <table class="messages"> 
+   <tr>
+      <th>To</th>
+      <th>Subject</th>
+      <th>Sent</th>
+      <th>Received</th>
+      <th><input type="checkbox" onclick="tabMasterCheckClick(this, 'outbox_ids');" /></th>
+   </tr>
+   {% for msg in msgs.object_list %}
+   <tr>
+      <td><a href="{% url 'bio.views.view_profile' msg.receiver.username %}">
+         {{ msg.receiver.username }}</a></td>
+      <td>
+         <a href="#" onclick="msgShow(this, {{ msg.id }}); return false;"
+            class="{% if msg.unread %}unread {% endif %}{% if msg.replied_to %}replied_to{% endif %}">{{ msg.subject }}</a>
+         </td>
+      <td>{{ msg.send_date|date:"M j, Y g:i A" }}</td>
+      <td>{% if msg.unread %}<em>Unread</em>{% else %}{{ msg.read_date|date:"M j, Y g:i A" }}{% endif %}</td>
+      <td><input type="checkbox" name="outbox_ids" value="{{ msg.id }}" /></td>
+   </tr>
+   {% endfor %}
+   <tr><td colspan="5"><input type="submit" value="Delete Checked Messages" /></td></tr>
+   </table>
+   </form>
+   {% include "messages/pagination.html" %}
+   <ul>
+      <li>Messages in <strong>bold</strong> are unread.</li>
+      <li>Messages in <em>italics</em> have been replied to.</li>
+   </ul>
+{% else %}
+   <p><em>Your Outbox is empty.</em></p>
+{% endif %}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/templates/messages/pagination.html	Tue Apr 26 00:16:35 2011 +0000
@@ -0,0 +1,15 @@
+<div class="pagination">
+<span class="step-links">
+{% if msgs.has_previous %}
+  <a href="{{ url }}?page={{ msgs.previous_page_number }}" onclick="return ajaxPageFetch(this);">&laquo; Previous</a>
+{% endif %}
+
+<span class="current">
+   Page {{ msgs.number }} of {{ msgs.paginator.num_pages }}.
+</span>
+
+{% if msgs.has_next %}
+   <a href="{{ url }}?page={{ msgs.next_page_number }}" onclick="return ajaxPageFetch(this);">Next &raquo;</a>
+{% endif %}
+ </span>
+</div>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/templates/messages/tabbed_base.html	Tue Apr 26 00:16:35 2011 +0000
@@ -0,0 +1,37 @@
+{% extends 'base.html' %}
+{% load url from future %}
+{% load script_tags %}
+{% block custom_css %}
+<link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}css/messages.css" />
+{% endblock %}
+{% block custom_js %}
+{% script_tags 'jquery-ui' %}
+<script type="text/javascript">
+//<![CDATA[
+   var initialTab = {{ tab }};
+   var username = "{{ request.user.username }}";
+//]]>
+</script>
+<script type="text/javascript" src="{{ STATIC_URL }}js/tabbed_messages.js"></script>
+{% endblock %}
+{% block content %}
+<h2>Your Private Messages (Beta)</h2>
+<p>
+This is an experimental version of the SG101 private message system. Don't worry, if this
+isn't working for you, you can always go back to the
+<a href="{% url 'messages-inbox' %}">current system</a>. Please leave any feedback on this
+change in forums. Thanks!
+</p>
+
+<div id="tabs">
+   <ul>
+      <li><a href="{% url 'messages-beta_inbox' %}">Inbox</a></li>
+      <li><a href="{% url 'messages-beta_compose' %}">Compose</a></li>
+      <li><a href="{% url 'messages-beta_outbox' %}">Outbox</a></li>
+      <li><a href="{% url 'messages-beta_trash' %}">Trash</a></li>
+      <li><a href="{% url 'messages-beta_options' %}">Options</a></li>
+   </ul>
+</div>
+
+<div id="msgDialog"></div>
+{% endblock %}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/templates/messages/trash_tab.html	Tue Apr 26 00:16:35 2011 +0000
@@ -0,0 +1,43 @@
+{% load url from future %}
+{% if messages %}
+<ul class="user-messages">
+   {% for msg in messages %}
+      <li{% if msg.tags %} class="{{ msg.tags }}"{% endif %}>{{ msg }}</li>
+   {% endfor %}
+</ul>
+{% endif %}
+{% if msgs.object_list %}
+   <form action="." onsubmit="return bulkMsgAction(this, 'undelete');">
+   <table class="messages">
+   <tr>
+      <th>From</th>
+      <th>To</th>
+      <th>Subject</th>
+      <th>Date</th>
+      <th><input type="checkbox" onclick="tabMasterCheckClick(this, 'trash_ids');" /></th>
+   </tr>
+   {% for msg in msgs.object_list %}
+   <tr>
+      <td><a href="{% url 'bio.views.view_profile' msg.sender.username %}">
+         {{ msg.sender.username }}</a></td>
+      <td><a href="{% url 'bio.views.view_profile' msg.receiver.username %}">
+         {{ msg.receiver.username }}</a></td>
+      <td>
+         <a href="#" onclick="msgShow(this, {{ msg.id }}); return false;"
+            class="{% if msg.unread %}unread {% endif %}{% if msg.replied_to %}replied_to{% endif %}">{{ msg.subject }}</a>
+         </td>
+      <td>{{ msg.send_date|date:"M j, Y g:i:s A T" }}</td>
+      <td><input type="checkbox" name="trash_ids" value="{{ msg.id }}" /></td>
+   </tr>
+   {% endfor %}
+   <tr><td colspan="5"><input type="submit" value="Undelete Checked Messages" /></td></tr>
+   </table>
+   </form>
+   {% include "messages/pagination.html" %}
+   <ul>
+      <li>Messages in <strong>bold</strong> are unread.</li>
+      <li>Messages in <em>italics</em> have been replied to.</li>
+   </ul>
+{% else %}
+   <p><em>Your Trash is empty.</em></p>
+{% endif %}