Browse Source

#477: WIP start private thread form

Rafał Pitoń 10 years ago
parent
commit
ef316278a0

+ 3 - 1
misago/conf/defaults.py

@@ -57,7 +57,7 @@ PIPELINE_JS = {
             'misago/js/bootstrap.js',
             'misago/js/moment.min.js',
             'misago/js/tinycon.min.js',
-            'misago/js/knockout.js',
+            'misago/js/typeahead.jquery.min.js',
             'misago/js/misago.js',
             'misago/js/misago-storage.js',
             'misago/js/misago-alerts.js',
@@ -76,6 +76,7 @@ PIPELINE_JS = {
             'misago/js/misago-threads-lists.js',
             'misago/js/misago-onebox.js',
             'misago/js/misago-posting.js',
+            'misago/js/misago-posting-participants.js',
             'misago/js/misago-posts.js',
         ),
         'output_filename': 'misago.js',
@@ -191,6 +192,7 @@ MISAGO_POSTING_MIDDLEWARES = (
     # Note: always keep FloodProtectionMiddleware middleware first one
     'misago.threads.posting.floodprotection.FloodProtectionMiddleware',
     'misago.threads.posting.reply.ReplyFormMiddleware',
+    'misago.threads.posting.participants.ThreadParticipantsFormMiddleware',
     'misago.threads.posting.threadlabel.ThreadLabelFormMiddleware',
     'misago.threads.posting.threadpin.ThreadPinFormMiddleware',
     'misago.threads.posting.threadclose.ThreadCloseFormMiddleware',

+ 109 - 0
misago/static/misago/css/misago/posting.less

@@ -79,6 +79,115 @@
     }
   }
 
+  .thread-participants {
+    background: @input-bg;
+    border: 2px solid @input-border;
+    border-radius: @border-radius-large;
+    margin-bottom: @line-height-computed / 2;
+
+    ul {
+      float: left;
+
+      li {
+        background: darken(@input-bg, 5%);
+        border-radius: @border-radius-small;
+        float: left;
+        margin-left: 2px;
+        margin-right: @line-height-computed / 2;
+        margin-top: 2px;
+        padding: @padding-small-vertical @padding-small-horizontal;
+        padding-left: @padding-small-vertical + 1px;
+
+        font-weight: bold;
+
+        img {
+          border-radius: @border-radius-small;
+          height: 20px;
+          margin-right: @line-height-computed / 5;
+          margin-bottom: -2px;
+          position: relative;
+          bottom: 2px;
+        }
+
+        .btn {
+          background: transparent;
+          border-color: transparent;
+          margin-left: @line-height-computed / 4;
+          height: 17px;
+          width: 17px;
+          padding: 0px;
+          position: relative;
+          bottom: 2px;
+
+          text-align: center;
+
+          .fa {
+            position: relative;
+            left: 0px;
+            bottom: 1px;
+
+            font-size: 14px;
+          }
+        }
+      }
+    }
+
+    .user-input {
+      float: left;
+      border: none;
+      .box-shadow(none);
+    }
+
+    .twitter-typeahead {
+      margin-bottom: -5px;
+
+      .tt-dropdown-menu {
+        background-color: @dropdown-bg;
+        border-radius: @border-radius-base;
+        box-shadow: 0px 0px 0px 4px @dropdown-shadow;
+        min-width: 160px;
+        padding: @padding-base-vertical 0px;
+
+        &:after {
+          border: solid transparent;
+          border-bottom-color: @dropdown-bg;
+          border-width: 4px;
+          content: "";
+          height: 0;
+          position: absolute;
+          pointer-events: none;
+          left: 12px;
+          top: -8px;
+          width: 0;
+        }
+
+        .tt-suggestion {
+          display: block;
+          padding: @padding-base-vertical @padding-large-horizontal;
+
+          color: @dropdown-link-color;
+          font-weight: bold;
+
+          cursor: pointer;
+
+          img {
+            border-radius: @border-radius-small;
+            position: relative;
+            bottom: 1px;
+          }
+
+          &.tt-cursor {
+            &, a:hover, a:active {
+              background-color: darken(@dropdown-bg, 10%);
+
+              color: @dropdown-link-color;
+            }
+          }
+        }
+      }
+    }
+  }
+
   .editor-preview {
     border: 2px dashed @form-panel-border;
     border-radius: @border-radius-large;

+ 72 - 0
misago/static/misago/js/misago-posting-participants.js

@@ -0,0 +1,72 @@
+Misago.participants = function($e) {
+
+  this.$users = $e.find('.users-list');
+  this.$input = $e.find('.user-input');
+  this.$value = $e.find('input[type=hidden]');
+
+  this.api_url = this.$input.data('api-url');
+  this.csrf_token = $e.parents('form').find("input[name=csrfmiddlewaretoken]").val()
+
+  var _this = this;
+
+  this.add_user = function(user) {
+
+    if (!this.$users.find('.user-' + user.username).length) {
+      var $user = $('<li class="user-' + user.username + '" data-username="' + user.username + '">' + user.username + '</li>');
+      var $cancel = $('<button type="button" class="btn btn-sm"><span class="fa fa-times"></span></button>')
+
+      $cancel.click(function() {
+        $cancel.parent().remove();
+        _this.update_val();
+      });
+
+      var $avatar = $('<img src="' + user.avatar[Object.keys(user.avatar)[0]] + '" alt="">');
+
+      $user.prepend($avatar);
+      $user.append($cancel);
+
+      this.$users.append($user);
+      this.update_val();
+    }
+
+  }
+
+  this.update_val = function() {
+
+    var usernames = [];
+
+    this.$users.find('li').each(function(index, el) {
+      usernames.push($(el).data('username'));
+    });
+
+    this.$value.val(usernames.join(',', usernames));
+
+  }
+
+  this.$input.typeahead({
+      minLength: 2,
+      hint: false,
+    },
+    {
+      name: 'profiles',
+      displayKey: 'username',
+      source: function(query, cb) {
+        var POST = {'username': query, 'csrfmiddlewaretoken': _this.csrf_token};
+        return $.post(_this.api_url, POST, function (data) {
+          return cb(data.profiles);
+        });
+      },
+      templates: {
+        suggestion: function(data) {
+          return '<img src="' + data.avatar[Object.keys(data.avatar)[0]] + '" alt=""> ' + data.username;
+        }
+      }
+    });
+
+  this.$input.on('typeahead:selected', function(e, suggestion, dataset) {
+
+    _this.$input.typeahead('val', '');
+    _this.add_user(suggestion);
+
+  });
+}

+ 7 - 0
misago/static/misago/js/typeahead.jquery.min.js

@@ -0,0 +1,7 @@
+/*!
+ * typeahead.js 0.10.5
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+!function(a){var b=function(){"use strict";return{isMsie:function(){return/(msie|trident)/i.test(navigator.userAgent)?navigator.userAgent.match(/(msie |rv:)(\d+(.\d+)?)/i)[2]:!1},isBlankString:function(a){return!a||/^\s*$/.test(a)},escapeRegExChars:function(a){return a.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")},isString:function(a){return"string"==typeof a},isNumber:function(a){return"number"==typeof a},isArray:a.isArray,isFunction:a.isFunction,isObject:a.isPlainObject,isUndefined:function(a){return"undefined"==typeof a},toStr:function(a){return b.isUndefined(a)||null===a?"":a+""},bind:a.proxy,each:function(b,c){function d(a,b){return c(b,a)}a.each(b,d)},map:a.map,filter:a.grep,every:function(b,c){var d=!0;return b?(a.each(b,function(a,e){return(d=c.call(null,e,a,b))?void 0:!1}),!!d):d},some:function(b,c){var d=!1;return b?(a.each(b,function(a,e){return(d=c.call(null,e,a,b))?!1:void 0}),!!d):d},mixin:a.extend,getUniqueId:function(){var a=0;return function(){return a++}}(),templatify:function(b){function c(){return String(b)}return a.isFunction(b)?b:c},defer:function(a){setTimeout(a,0)},debounce:function(a,b,c){var d,e;return function(){var f,g,h=this,i=arguments;return f=function(){d=null,c||(e=a.apply(h,i))},g=c&&!d,clearTimeout(d),d=setTimeout(f,b),g&&(e=a.apply(h,i)),e}},throttle:function(a,b){var c,d,e,f,g,h;return g=0,h=function(){g=new Date,e=null,f=a.apply(c,d)},function(){var i=new Date,j=b-(i-g);return c=this,d=arguments,0>=j?(clearTimeout(e),e=null,g=i,f=a.apply(c,d)):e||(e=setTimeout(h,j)),f}},noop:function(){}}}(),c=function(){return{wrapper:'<span class="twitter-typeahead"></span>',dropdown:'<span class="tt-dropdown-menu"></span>',dataset:'<div class="tt-dataset-%CLASS%"></div>',suggestions:'<span class="tt-suggestions"></span>',suggestion:'<div class="tt-suggestion"></div>'}}(),d=function(){"use strict";var a={wrapper:{position:"relative",display:"inline-block"},hint:{position:"absolute",top:"0",left:"0",borderColor:"transparent",boxShadow:"none",opacity:"1"},input:{position:"relative",verticalAlign:"top",backgroundColor:"transparent"},inputWithNoHint:{position:"relative",verticalAlign:"top"},dropdown:{position:"absolute",top:"100%",left:"0",zIndex:"100",display:"none"},suggestions:{display:"block"},suggestion:{whiteSpace:"nowrap",cursor:"pointer"},suggestionChild:{whiteSpace:"normal"},ltr:{left:"0",right:"auto"},rtl:{left:"auto",right:" 0"}};return b.isMsie()&&b.mixin(a.input,{backgroundImage:"url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)"}),b.isMsie()&&b.isMsie()<=7&&b.mixin(a.input,{marginTop:"-1px"}),a}(),e=function(){"use strict";function c(b){b&&b.el||a.error("EventBus initialized without el"),this.$el=a(b.el)}var d="typeahead:";return b.mixin(c.prototype,{trigger:function(a){var b=[].slice.call(arguments,1);this.$el.trigger(d+a,b)}}),c}(),f=function(){"use strict";function a(a,b,c,d){var e;if(!c)return this;for(b=b.split(i),c=d?h(c,d):c,this._callbacks=this._callbacks||{};e=b.shift();)this._callbacks[e]=this._callbacks[e]||{sync:[],async:[]},this._callbacks[e][a].push(c);return this}function b(b,c,d){return a.call(this,"async",b,c,d)}function c(b,c,d){return a.call(this,"sync",b,c,d)}function d(a){var b;if(!this._callbacks)return this;for(a=a.split(i);b=a.shift();)delete this._callbacks[b];return this}function e(a){var b,c,d,e,g;if(!this._callbacks)return this;for(a=a.split(i),d=[].slice.call(arguments,1);(b=a.shift())&&(c=this._callbacks[b]);)e=f(c.sync,this,[b].concat(d)),g=f(c.async,this,[b].concat(d)),e()&&j(g);return this}function f(a,b,c){function d(){for(var d,e=0,f=a.length;!d&&f>e;e+=1)d=a[e].apply(b,c)===!1;return!d}return d}function g(){var a;return a=window.setImmediate?function(a){setImmediate(function(){a()})}:function(a){setTimeout(function(){a()},0)}}function h(a,b){return a.bind?a.bind(b):function(){a.apply(b,[].slice.call(arguments,0))}}var i=/\s+/,j=g();return{onSync:c,onAsync:b,off:d,trigger:e}}(),g=function(a){"use strict";function c(a,c,d){for(var e,f=[],g=0,h=a.length;h>g;g++)f.push(b.escapeRegExChars(a[g]));return e=d?"\\b("+f.join("|")+")\\b":"("+f.join("|")+")",c?new RegExp(e):new RegExp(e,"i")}var d={node:null,pattern:null,tagName:"strong",className:null,wordsOnly:!1,caseSensitive:!1};return function(e){function f(b){var c,d,f;return(c=h.exec(b.data))&&(f=a.createElement(e.tagName),e.className&&(f.className=e.className),d=b.splitText(c.index),d.splitText(c[0].length),f.appendChild(d.cloneNode(!0)),b.parentNode.replaceChild(f,d)),!!c}function g(a,b){for(var c,d=3,e=0;e<a.childNodes.length;e++)c=a.childNodes[e],c.nodeType===d?e+=b(c)?1:0:g(c,b)}var h;e=b.mixin({},d,e),e.node&&e.pattern&&(e.pattern=b.isArray(e.pattern)?e.pattern:[e.pattern],h=c(e.pattern,e.caseSensitive,e.wordsOnly),g(e.node,f))}}(window.document),h=function(){"use strict";function c(c){var e,f,g,i,j=this;c=c||{},c.input||a.error("input is missing"),e=b.bind(this._onBlur,this),f=b.bind(this._onFocus,this),g=b.bind(this._onKeydown,this),i=b.bind(this._onInput,this),this.$hint=a(c.hint),this.$input=a(c.input).on("blur.tt",e).on("focus.tt",f).on("keydown.tt",g),0===this.$hint.length&&(this.setHint=this.getHint=this.clearHint=this.clearHintIfInvalid=b.noop),b.isMsie()?this.$input.on("keydown.tt keypress.tt cut.tt paste.tt",function(a){h[a.which||a.keyCode]||b.defer(b.bind(j._onInput,j,a))}):this.$input.on("input.tt",i),this.query=this.$input.val(),this.$overflowHelper=d(this.$input)}function d(b){return a('<pre aria-hidden="true"></pre>').css({position:"absolute",visibility:"hidden",whiteSpace:"pre",fontFamily:b.css("font-family"),fontSize:b.css("font-size"),fontStyle:b.css("font-style"),fontVariant:b.css("font-variant"),fontWeight:b.css("font-weight"),wordSpacing:b.css("word-spacing"),letterSpacing:b.css("letter-spacing"),textIndent:b.css("text-indent"),textRendering:b.css("text-rendering"),textTransform:b.css("text-transform")}).insertAfter(b)}function e(a,b){return c.normalizeQuery(a)===c.normalizeQuery(b)}function g(a){return a.altKey||a.ctrlKey||a.metaKey||a.shiftKey}var h;return h={9:"tab",27:"esc",37:"left",39:"right",13:"enter",38:"up",40:"down"},c.normalizeQuery=function(a){return(a||"").replace(/^\s*/g,"").replace(/\s{2,}/g," ")},b.mixin(c.prototype,f,{_onBlur:function(){this.resetInputValue(),this.trigger("blurred")},_onFocus:function(){this.trigger("focused")},_onKeydown:function(a){var b=h[a.which||a.keyCode];this._managePreventDefault(b,a),b&&this._shouldTrigger(b,a)&&this.trigger(b+"Keyed",a)},_onInput:function(){this._checkInputValue()},_managePreventDefault:function(a,b){var c,d,e;switch(a){case"tab":d=this.getHint(),e=this.getInputValue(),c=d&&d!==e&&!g(b);break;case"up":case"down":c=!g(b);break;default:c=!1}c&&b.preventDefault()},_shouldTrigger:function(a,b){var c;switch(a){case"tab":c=!g(b);break;default:c=!0}return c},_checkInputValue:function(){var a,b,c;a=this.getInputValue(),b=e(a,this.query),c=b?this.query.length!==a.length:!1,this.query=a,b?c&&this.trigger("whitespaceChanged",this.query):this.trigger("queryChanged",this.query)},focus:function(){this.$input.focus()},blur:function(){this.$input.blur()},getQuery:function(){return this.query},setQuery:function(a){this.query=a},getInputValue:function(){return this.$input.val()},setInputValue:function(a,b){this.$input.val(a),b?this.clearHint():this._checkInputValue()},resetInputValue:function(){this.setInputValue(this.query,!0)},getHint:function(){return this.$hint.val()},setHint:function(a){this.$hint.val(a)},clearHint:function(){this.setHint("")},clearHintIfInvalid:function(){var a,b,c,d;a=this.getInputValue(),b=this.getHint(),c=a!==b&&0===b.indexOf(a),d=""!==a&&c&&!this.hasOverflow(),!d&&this.clearHint()},getLanguageDirection:function(){return(this.$input.css("direction")||"ltr").toLowerCase()},hasOverflow:function(){var a=this.$input.width()-2;return this.$overflowHelper.text(this.getInputValue()),this.$overflowHelper.width()>=a},isCursorAtEnd:function(){var a,c,d;return a=this.$input.val().length,c=this.$input[0].selectionStart,b.isNumber(c)?c===a:document.selection?(d=document.selection.createRange(),d.moveStart("character",-a),a===d.text.length):!0},destroy:function(){this.$hint.off(".tt"),this.$input.off(".tt"),this.$hint=this.$input=this.$overflowHelper=null}}),c}(),i=function(){"use strict";function e(d){d=d||{},d.templates=d.templates||{},d.source||a.error("missing source"),d.name&&!j(d.name)&&a.error("invalid dataset name: "+d.name),this.query=null,this.highlight=!!d.highlight,this.name=d.name||b.getUniqueId(),this.source=d.source,this.displayFn=h(d.display||d.displayKey),this.templates=i(d.templates,this.displayFn),this.$el=a(c.dataset.replace("%CLASS%",this.name))}function h(a){function c(b){return b[a]}return a=a||"value",b.isFunction(a)?a:c}function i(a,c){function d(a){return"<p>"+c(a)+"</p>"}return{empty:a.empty&&b.templatify(a.empty),header:a.header&&b.templatify(a.header),footer:a.footer&&b.templatify(a.footer),suggestion:a.suggestion||d}}function j(a){return/^[_a-zA-Z0-9-]+$/.test(a)}var k="ttDataset",l="ttValue",m="ttDatum";return e.extractDatasetName=function(b){return a(b).data(k)},e.extractValue=function(b){return a(b).data(l)},e.extractDatum=function(b){return a(b).data(m)},b.mixin(e.prototype,f,{_render:function(e,f){function h(){return p.templates.empty({query:e,isEmpty:!0})}function i(){function h(b){var e;return e=a(c.suggestion).append(p.templates.suggestion(b)).data(k,p.name).data(l,p.displayFn(b)).data(m,b),e.children().each(function(){a(this).css(d.suggestionChild)}),e}var i,j;return i=a(c.suggestions).css(d.suggestions),j=b.map(f,h),i.append.apply(i,j),p.highlight&&g({className:"tt-highlight",node:i[0],pattern:e}),i}function j(){return p.templates.header({query:e,isEmpty:!o})}function n(){return p.templates.footer({query:e,isEmpty:!o})}if(this.$el){var o,p=this;this.$el.empty(),o=f&&f.length,!o&&this.templates.empty?this.$el.html(h()).prepend(p.templates.header?j():null).append(p.templates.footer?n():null):o&&this.$el.html(i()).prepend(p.templates.header?j():null).append(p.templates.footer?n():null),this.trigger("rendered")}},getRoot:function(){return this.$el},update:function(a){function b(b){c.canceled||a!==c.query||c._render(a,b)}var c=this;this.query=a,this.canceled=!1,this.source(a,b)},cancel:function(){this.canceled=!0},clear:function(){this.cancel(),this.$el.empty(),this.trigger("rendered")},isEmpty:function(){return this.$el.is(":empty")},destroy:function(){this.$el=null}}),e}(),j=function(){"use strict";function c(c){var d,f,g,h=this;c=c||{},c.menu||a.error("menu is required"),this.isOpen=!1,this.isEmpty=!0,this.datasets=b.map(c.datasets,e),d=b.bind(this._onSuggestionClick,this),f=b.bind(this._onSuggestionMouseEnter,this),g=b.bind(this._onSuggestionMouseLeave,this),this.$menu=a(c.menu).on("click.tt",".tt-suggestion",d).on("mouseenter.tt",".tt-suggestion",f).on("mouseleave.tt",".tt-suggestion",g),b.each(this.datasets,function(a){h.$menu.append(a.getRoot()),a.onSync("rendered",h._onRendered,h)})}function e(a){return new i(a)}return b.mixin(c.prototype,f,{_onSuggestionClick:function(b){this.trigger("suggestionClicked",a(b.currentTarget))},_onSuggestionMouseEnter:function(b){this._removeCursor(),this._setCursor(a(b.currentTarget),!0)},_onSuggestionMouseLeave:function(){this._removeCursor()},_onRendered:function(){function a(a){return a.isEmpty()}this.isEmpty=b.every(this.datasets,a),this.isEmpty?this._hide():this.isOpen&&this._show(),this.trigger("datasetRendered")},_hide:function(){this.$menu.hide()},_show:function(){this.$menu.css("display","block")},_getSuggestions:function(){return this.$menu.find(".tt-suggestion")},_getCursor:function(){return this.$menu.find(".tt-cursor").first()},_setCursor:function(a,b){a.first().addClass("tt-cursor"),!b&&this.trigger("cursorMoved")},_removeCursor:function(){this._getCursor().removeClass("tt-cursor")},_moveCursor:function(a){var b,c,d,e;if(this.isOpen){if(c=this._getCursor(),b=this._getSuggestions(),this._removeCursor(),d=b.index(c)+a,d=(d+1)%(b.length+1)-1,-1===d)return void this.trigger("cursorRemoved");-1>d&&(d=b.length-1),this._setCursor(e=b.eq(d)),this._ensureVisible(e)}},_ensureVisible:function(a){var b,c,d,e;b=a.position().top,c=b+a.outerHeight(!0),d=this.$menu.scrollTop(),e=this.$menu.height()+parseInt(this.$menu.css("paddingTop"),10)+parseInt(this.$menu.css("paddingBottom"),10),0>b?this.$menu.scrollTop(d+b):c>e&&this.$menu.scrollTop(d+(c-e))},close:function(){this.isOpen&&(this.isOpen=!1,this._removeCursor(),this._hide(),this.trigger("closed"))},open:function(){this.isOpen||(this.isOpen=!0,!this.isEmpty&&this._show(),this.trigger("opened"))},setLanguageDirection:function(a){this.$menu.css("ltr"===a?d.ltr:d.rtl)},moveCursorUp:function(){this._moveCursor(-1)},moveCursorDown:function(){this._moveCursor(1)},getDatumForSuggestion:function(a){var b=null;return a.length&&(b={raw:i.extractDatum(a),value:i.extractValue(a),datasetName:i.extractDatasetName(a)}),b},getDatumForCursor:function(){return this.getDatumForSuggestion(this._getCursor().first())},getDatumForTopSuggestion:function(){return this.getDatumForSuggestion(this._getSuggestions().first())},update:function(a){function c(b){b.update(a)}b.each(this.datasets,c)},empty:function(){function a(a){a.clear()}b.each(this.datasets,a),this.isEmpty=!0},isVisible:function(){return this.isOpen&&!this.isEmpty},destroy:function(){function a(a){a.destroy()}this.$menu.off(".tt"),this.$menu=null,b.each(this.datasets,a)}}),c}(),k=function(){"use strict";function f(c){var d,f,i;c=c||{},c.input||a.error("missing input"),this.isActivated=!1,this.autoselect=!!c.autoselect,this.minLength=b.isNumber(c.minLength)?c.minLength:1,this.$node=g(c.input,c.withHint),d=this.$node.find(".tt-dropdown-menu"),f=this.$node.find(".tt-input"),i=this.$node.find(".tt-hint"),f.on("blur.tt",function(a){var c,e,g;c=document.activeElement,e=d.is(c),g=d.has(c).length>0,b.isMsie()&&(e||g)&&(a.preventDefault(),a.stopImmediatePropagation(),b.defer(function(){f.focus()}))}),d.on("mousedown.tt",function(a){a.preventDefault()}),this.eventBus=c.eventBus||new e({el:f}),this.dropdown=new j({menu:d,datasets:c.datasets}).onSync("suggestionClicked",this._onSuggestionClicked,this).onSync("cursorMoved",this._onCursorMoved,this).onSync("cursorRemoved",this._onCursorRemoved,this).onSync("opened",this._onOpened,this).onSync("closed",this._onClosed,this).onAsync("datasetRendered",this._onDatasetRendered,this),this.input=new h({input:f,hint:i}).onSync("focused",this._onFocused,this).onSync("blurred",this._onBlurred,this).onSync("enterKeyed",this._onEnterKeyed,this).onSync("tabKeyed",this._onTabKeyed,this).onSync("escKeyed",this._onEscKeyed,this).onSync("upKeyed",this._onUpKeyed,this).onSync("downKeyed",this._onDownKeyed,this).onSync("leftKeyed",this._onLeftKeyed,this).onSync("rightKeyed",this._onRightKeyed,this).onSync("queryChanged",this._onQueryChanged,this).onSync("whitespaceChanged",this._onWhitespaceChanged,this),this._setLanguageDirection()}function g(b,e){var f,g,h,j;f=a(b),g=a(c.wrapper).css(d.wrapper),h=a(c.dropdown).css(d.dropdown),j=f.clone().css(d.hint).css(i(f)),j.val("").removeData().addClass("tt-hint").removeAttr("id name placeholder required").prop("readonly",!0).attr({autocomplete:"off",spellcheck:"false",tabindex:-1}),f.data(l,{dir:f.attr("dir"),autocomplete:f.attr("autocomplete"),spellcheck:f.attr("spellcheck"),style:f.attr("style")}),f.addClass("tt-input").attr({autocomplete:"off",spellcheck:!1}).css(e?d.input:d.inputWithNoHint);try{!f.attr("dir")&&f.attr("dir","auto")}catch(k){}return f.wrap(g).parent().prepend(e?j:null).append(h)}function i(a){return{backgroundAttachment:a.css("background-attachment"),backgroundClip:a.css("background-clip"),backgroundColor:a.css("background-color"),backgroundImage:a.css("background-image"),backgroundOrigin:a.css("background-origin"),backgroundPosition:a.css("background-position"),backgroundRepeat:a.css("background-repeat"),backgroundSize:a.css("background-size")}}function k(a){var c=a.find(".tt-input");b.each(c.data(l),function(a,d){b.isUndefined(a)?c.removeAttr(d):c.attr(d,a)}),c.detach().removeData(l).removeClass("tt-input").insertAfter(a),a.remove()}var l="ttAttrs";return b.mixin(f.prototype,{_onSuggestionClicked:function(a,b){var c;(c=this.dropdown.getDatumForSuggestion(b))&&this._select(c)},_onCursorMoved:function(){var a=this.dropdown.getDatumForCursor();this.input.setInputValue(a.value,!0),this.eventBus.trigger("cursorchanged",a.raw,a.datasetName)},_onCursorRemoved:function(){this.input.resetInputValue(),this._updateHint()},_onDatasetRendered:function(){this._updateHint()},_onOpened:function(){this._updateHint(),this.eventBus.trigger("opened")},_onClosed:function(){this.input.clearHint(),this.eventBus.trigger("closed")},_onFocused:function(){this.isActivated=!0,this.dropdown.open()},_onBlurred:function(){this.isActivated=!1,this.dropdown.empty(),this.dropdown.close()},_onEnterKeyed:function(a,b){var c,d;c=this.dropdown.getDatumForCursor(),d=this.dropdown.getDatumForTopSuggestion(),c?(this._select(c),b.preventDefault()):this.autoselect&&d&&(this._select(d),b.preventDefault())},_onTabKeyed:function(a,b){var c;(c=this.dropdown.getDatumForCursor())?(this._select(c),b.preventDefault()):this._autocomplete(!0)},_onEscKeyed:function(){this.dropdown.close(),this.input.resetInputValue()},_onUpKeyed:function(){var a=this.input.getQuery();this.dropdown.isEmpty&&a.length>=this.minLength?this.dropdown.update(a):this.dropdown.moveCursorUp(),this.dropdown.open()},_onDownKeyed:function(){var a=this.input.getQuery();this.dropdown.isEmpty&&a.length>=this.minLength?this.dropdown.update(a):this.dropdown.moveCursorDown(),this.dropdown.open()},_onLeftKeyed:function(){"rtl"===this.dir&&this._autocomplete()},_onRightKeyed:function(){"ltr"===this.dir&&this._autocomplete()},_onQueryChanged:function(a,b){this.input.clearHintIfInvalid(),b.length>=this.minLength?this.dropdown.update(b):this.dropdown.empty(),this.dropdown.open(),this._setLanguageDirection()},_onWhitespaceChanged:function(){this._updateHint(),this.dropdown.open()},_setLanguageDirection:function(){var a;this.dir!==(a=this.input.getLanguageDirection())&&(this.dir=a,this.$node.css("direction",a),this.dropdown.setLanguageDirection(a))},_updateHint:function(){var a,c,d,e,f,g;a=this.dropdown.getDatumForTopSuggestion(),a&&this.dropdown.isVisible()&&!this.input.hasOverflow()?(c=this.input.getInputValue(),d=h.normalizeQuery(c),e=b.escapeRegExChars(d),f=new RegExp("^(?:"+e+")(.+$)","i"),g=f.exec(a.value),g?this.input.setHint(c+g[1]):this.input.clearHint()):this.input.clearHint()},_autocomplete:function(a){var b,c,d,e;b=this.input.getHint(),c=this.input.getQuery(),d=a||this.input.isCursorAtEnd(),b&&c!==b&&d&&(e=this.dropdown.getDatumForTopSuggestion(),e&&this.input.setInputValue(e.value),this.eventBus.trigger("autocompleted",e.raw,e.datasetName))},_select:function(a){this.input.setQuery(a.value),this.input.setInputValue(a.value,!0),this._setLanguageDirection(),this.eventBus.trigger("selected",a.raw,a.datasetName),this.dropdown.close(),b.defer(b.bind(this.dropdown.empty,this.dropdown))},open:function(){this.dropdown.open()},close:function(){this.dropdown.close()},setVal:function(a){a=b.toStr(a),this.isActivated?this.input.setInputValue(a):(this.input.setQuery(a),this.input.setInputValue(a,!0)),this._setLanguageDirection()},getVal:function(){return this.input.getQuery()},destroy:function(){this.input.destroy(),this.dropdown.destroy(),k(this.$node),this.$node=null}}),f}();!function(){"use strict";var c,d,f;c=a.fn.typeahead,d="ttTypeahead",f={initialize:function(c,f){function g(){var g,h,i=a(this);b.each(f,function(a){a.highlight=!!c.highlight}),h=new k({input:i,eventBus:g=new e({el:i}),withHint:b.isUndefined(c.hint)?!0:!!c.hint,minLength:c.minLength,autoselect:c.autoselect,datasets:f}),i.data(d,h)}return f=b.isArray(f)?f:[].slice.call(arguments,1),c=c||{},this.each(g)},open:function(){function b(){var b,c=a(this);(b=c.data(d))&&b.open()}return this.each(b)},close:function(){function b(){var b,c=a(this);(b=c.data(d))&&b.close()}return this.each(b)},val:function(b){function c(){var c,e=a(this);(c=e.data(d))&&c.setVal(b)}function e(a){var b,c;return(b=a.data(d))&&(c=b.getVal()),c}return arguments.length?this.each(c):e(this.first())},destroy:function(){function b(){var b,c=a(this);(b=c.data(d))&&(b.destroy(),c.removeData(d))}return this.each(b)}},a.fn.typeahead=function(b){var c;return f[b]&&"initialize"!==b?(c=this.filter(function(){return!!a(this).data(d)}),f[b].apply(c,[].slice.call(arguments,1))):f.initialize.apply(this,arguments)},a.fn.typeahead.noConflict=function(){return a.fn.typeahead=c,this}}()}(window.jQuery);

+ 5 - 1
misago/templates/misago/posting/replyform.html

@@ -1,11 +1,15 @@
 {% load i18n misago_editor misago_forms misago_shorthands %}
 {% include 'misago/form_errors.html' %}
 
+{% for form in supporting_forms.reply_top %}
+  {% include form.template %}
+{% endfor %}
+
 {% if form.title %}
 <div class="thread-title">
   <div class="row">
     <div class="col-md-{{ supporting_forms.after_title|yesno:"9,12" }}">
-      <input class="textinput textInput form-control title-input" id="{{ form.title.auto_id }}" name="title" type="text" {% if form.title.value %}value="{{ form.title.value }}"{% endif %} placeholder="{% trans "Thread title..." %}"/>
+      <input class="textinput textInput form-control title-input" id="{{ form.title.auto_id }}" name="title" type="text" {% if form.title.value %}value="{{ form.title.value }}"{% endif %} placeholder="{% trans "Thread title..." %}">
     </div>
 
     {% if supporting_forms.after_title %}

+ 6 - 0
misago/templates/misago/posting/threadparticipantsform.html

@@ -0,0 +1,6 @@
+{% load i18n %}
+<div class="thread-participants">
+  <input id="{{ form.users.auto_id }}" name="users" type="hidden" {% if form.users.value %}value="{{ form.users.value }}"{% endif %}>
+  <ul class="list-unstyled users-list"></ul>
+  <input class="textinput textInput form-control user-input" id="{{ form.title.auto_id }}" name="title" type="text" placeholder="{% trans "User to message..." %}" data-api-url="{% url 'misago:api_suggestion_engine' %}">
+</div>

+ 24 - 3
misago/templates/misago/profile/base.html

@@ -1,5 +1,5 @@
 {% extends "misago/base.html" %}
-{% load i18n %}
+{% load i18n misago_avatars %}
 
 
 {% block title %}{{ profile }}: {{ active_page.name }} {% if page_number > 1 %}({% blocktrans with page=page_number %}Page {{ number }}{% endblocktrans %}) {% endif %}| {{ block.super }}{% endblock title %}
@@ -39,13 +39,16 @@
     </div>
   </div>
 </div>
+<div id="reply-form-placeholder"></div>
 {% endblock content %}
 
+
 {% block javascripts %}
 {% include "misago/modusers/mod_js.html" %}
-{% if profile.acl_.can_have_attitude %}
 <script type="text/javascript">
   $(function() {
+
+    {% if profile.acl_.can_have_attitude %}
     $('.dynamic-button').submit(function() {
       var $form = $(this);
       var $button = $form.find('button');
@@ -59,7 +62,25 @@
       });
       return false;
     });
+    {% endif %}
+
+    {% if can_message %}
+    $('.btn-message').click(function() {
+
+      Misago.Posting.load({
+        api_url: "{% url 'misago:start_private_thread' %}",
+        on_load: function() {
+          var participants = new Misago.participants($('.thread-participants'));
+          participants.add_user({
+            username: "{{ profile.username }}",
+            avatar: {30: "{{ profile|avatar:30 }}"}
+          })
+        }
+      });
+
+    });
+    {% endif %}
+
   });
 </script>
-{% endif %}
 {% endblock javascripts %}

+ 1 - 1
misago/templates/misago/profile/header.html

@@ -20,7 +20,7 @@
     {% trans "Message" %}
   </button>
   {% elif not is_authenticated_user %}
-  <button class="btn btn-default tooltip-bottom" disabled="disabled" title="{{ cant_message_reason }}">
+  <button class="btn btn-default tooltip-bottom pull-left" disabled="disabled" title="{{ cant_message_reason }}">
     <span class="fa fa-envelope-o"></span>
     {% trans "Message" %}
   </button>

+ 12 - 1
misago/threads/forms/posting.py

@@ -11,7 +11,6 @@ class ReplyForm(forms.Form):
     is_main = True
     legend = _("Reply")
     template = "misago/posting/replyform.html"
-    js_template = "misago/posting/replyform_js.html"
 
     post = forms.CharField(label=_("Message body"), required=False)
 
@@ -93,6 +92,18 @@ class ThreadForm(ReplyForm):
             raise forms.ValidationError(errors)
 
 
+class ThreadParticipantsForm(forms.Form):
+    is_supporting = True
+    location = 'reply_top'
+    template = "misago/posting/threadparticipantsform.html"
+
+    users = forms.CharField(label=_("Invite users to thread"))
+
+    def __init__(self, thread=None, *args, **kwargs):
+        self.thread_instance = thread
+        super(ThreadParticipantsForm, self).__init__(*args, **kwargs)
+
+
 class ThreadLabelFormBase(forms.Form):
     is_supporting = True
     location = 'after_title'

+ 6 - 1
misago/threads/migrations/0001_initial.py

@@ -107,6 +107,11 @@ class Migration(migrations.Migration):
                 ('level', models.PositiveIntegerField(default=1)),
                 ('thread', models.ForeignKey(to='misago_threads.Thread')),
                 ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
+                ('replies', models.PositiveIntegerField(default=0)),
+                ('last_post_on', models.DateTimeField(null=True, blank=True)),
+                ('last_poster', models.ForeignKey(settings.AUTH_USER_MODEL, related_name='+', null=True, blank=True, on_delete=django.db.models.deletion.SET_NULL)),
+                ('last_poster_name', models.CharField(max_length=255, null=True, blank=True)),
+                ('last_poster_slug', models.CharField(max_length=255, null=True, blank=True)),
             ],
             options={
             },
@@ -115,7 +120,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='thread',
             name='participants',
-            field=models.ManyToManyField(related_name='private_thread_set', through='misago_threads.ThreadParticipant', to=settings.AUTH_USER_MODEL),
+            field=models.ManyToManyField(related_name='private_thread_set', through='misago_threads.ThreadParticipant', through_fields=('thread', 'user'), to=settings.AUTH_USER_MODEL),
             preserve_default=True,
         ),
         migrations.CreateModel(

+ 16 - 2
misago/threads/models/thread.py

@@ -64,7 +64,8 @@ class Thread(models.Model, PrivateThreadMixin):
 
     participants = models.ManyToManyField(settings.AUTH_USER_MODEL,
                                           related_name='private_thread_set',
-                                          through='ThreadParticipant')
+                                          through='ThreadParticipant',
+                                          through_fields=('thread', 'user'))
 
     class Meta:
         index_together = [
@@ -219,13 +220,26 @@ class ThreadParticipantManager(models.Manager):
         ThreadParticipant.objects.create(
             thread=thread,
             user=user,
-            level=PARTICIPANT_REMOVED)
+            level=PARTICIPANT_REMOVED,
+            replies=thread.replies,
+            last_post_on=thread.last_post_on,
+            last_poster_id=thread.last_poster_id,
+            last_poster_name=thread.last_poster_name,
+            last_poster_slug=thread.last_poster_slug)
 
 
 class ThreadParticipant(models.Model):
     thread = models.ForeignKey(Thread)
     user = models.ForeignKey(settings.AUTH_USER_MODEL)
     level = models.PositiveIntegerField(default=PARTICIPANT_ACTIVE)
+    replies = models.PositiveIntegerField(default=0)
+    last_post_on = models.DateTimeField(null=True, blank=True)
+    last_poster = models.ForeignKey(settings.AUTH_USER_MODEL,
+                                    related_name='+',
+                                    null=True, blank=True,
+                                    on_delete=models.SET_NULL)
+    last_poster_name = models.CharField(max_length=255, null=True, blank=True)
+    last_poster_slug = models.CharField(max_length=255, null=True, blank=True)
 
     objects = ThreadParticipantManager()
 

+ 3 - 0
misago/threads/permissions/privatethreads.py

@@ -97,6 +97,9 @@ can_use_private_threads = return_boolean(allow_use_private_threads)
 def allow_message_user(user, target):
     allow_use_private_threads(user)
 
+    if user == target:
+        raise PermissionDenied(_("You can't message yourself."))
+
     if not user.acl['can_start_private_threads']:
         raise PermissionDenied(_("You can't start private threads."))
 

+ 3 - 0
misago/threads/posting/__init__.py

@@ -28,6 +28,9 @@ class EditorFormset(object):
         self._forms_list = []
         self._forms_dict = {}
 
+        is_private = kwargs['forum'].special_role == "private_threads"
+        kwargs['is_private'] = is_private
+
         self.kwargs = kwargs
         self.__dict__.update(kwargs)
 

+ 14 - 0
misago/threads/posting/participants.py

@@ -0,0 +1,14 @@
+from misago.threads.forms.posting import ThreadParticipantsForm
+from misago.threads.posting import PostingMiddleware, START
+
+
+class ThreadParticipantsFormMiddleware(PostingMiddleware):
+    def use_this_middleware(self):
+        return self.is_private
+
+    def make_form(self):
+        if self.request.method == 'POST':
+            return ThreadParticipantsForm(
+                self.request.POST, prefix=self.prefix)
+        else:
+            return ThreadParticipantsForm(prefix=self.prefix)

+ 5 - 0
misago/threads/tests/test_threadparticipant_model.py

@@ -117,3 +117,8 @@ class ThreadParticipantTests(TestCase):
         self.assertTrue(participation.is_removed)
         self.assertFalse(participation.is_owner)
         self.assertEqual(user, participation.user)
+        self.assertEqual(self.thread.last_post_on, participation.last_post_on)
+        self.assertEqual(self.thread.last_poster_id,
+                         participation.last_poster_id)
+        self.assertEqual(self.thread.last_poster_name,
+                         participation.last_poster_name)

+ 27 - 9
misago/threads/urls/privatethreads.py

@@ -1,14 +1,32 @@
 from django.conf.urls import patterns, include, url
 
 
-from misago.threads.views.privatethreads import PrivateThreadsView
+from misago.threads.views.privatethreads import ThreadsView
 urlpatterns = patterns('',
-    url(r'^private-threads/$', PrivateThreadsView.as_view(), name='private_threads'),
-    url(r'^private-threads/(?P<page>\d+)/$', PrivateThreadsView.as_view(), name='private_threads'),
-    url(r'^private-threads/sort-(?P<sort>[\w-]+)/$', PrivateThreadsView.as_view(), name='private_threads'),
-    url(r'^private-threads/sort-(?P<sort>[\w-]+)/(?P<page>\d+)/$', PrivateThreadsView.as_view(), name='private_threads'),
-    url(r'^private-threads/show-(?P<show>[\w-]+)/$', PrivateThreadsView.as_view(), name='private_threads'),
-    url(r'^private-threads/show-(?P<show>[\w-]+)/(?P<page>\d+)/$', PrivateThreadsView.as_view(), name='private_threads'),
-    url(r'^private-threads/sort-(?P<sort>[\w-]+)/show-(?P<show>[\w-]+)/$', PrivateThreadsView.as_view(), name='private_threads'),
-    url(r'^private-threads/sort-(?P<sort>[\w-]+)/show-(?P<show>[\w-]+)/(?P<page>\d+)/$', PrivateThreadsView.as_view(), name='private_threads'),
+    url(r'^private-threads/$', ThreadsView.as_view(), name='private_threads'),
+    url(r'^private-threads/(?P<page>\d+)/$', ThreadsView.as_view(), name='private_threads'),
+    url(r'^private-threads/sort-(?P<sort>[\w-]+)/$', ThreadsView.as_view(), name='private_threads'),
+    url(r'^private-threads/sort-(?P<sort>[\w-]+)/(?P<page>\d+)/$', ThreadsView.as_view(), name='private_threads'),
+    url(r'^private-threads/show-(?P<show>[\w-]+)/$', ThreadsView.as_view(), name='private_threads'),
+    url(r'^private-threads/show-(?P<show>[\w-]+)/(?P<page>\d+)/$', ThreadsView.as_view(), name='private_threads'),
+    url(r'^private-threads/sort-(?P<sort>[\w-]+)/show-(?P<show>[\w-]+)/$', ThreadsView.as_view(), name='private_threads'),
+    url(r'^private-threads/sort-(?P<sort>[\w-]+)/show-(?P<show>[\w-]+)/(?P<page>\d+)/$', ThreadsView.as_view(), name='private_threads'),
+)
+
+
+from misago.threads.views.privatethreads import (ThreadView, GotoLastView,
+                                                 GotoNewView, GotoPostView)
+urlpatterns += patterns('',
+    url(r'^private-thread/(?P<thread_slug>[\w\d-]+)-(?P<thread_id>\d+)/$', ThreadView.as_view(), name='private_thread'),
+    url(r'^private-thread/(?P<thread_slug>[\w\d-]+)-(?P<thread_id>\d+)/(?P<page>\d+)/$', ThreadView.as_view(), name='private_thread'),
+    url(r'^private-thread/(?P<thread_slug>[\w\d-]+)-(?P<thread_id>\d+)/last/$', GotoLastView.as_view(), name='private_thread_last'),
+    url(r'^private-thread/(?P<thread_slug>[\w\d-]+)-(?P<thread_id>\d+)/new/$', GotoNewView.as_view(), name='private_thread_new'),
+    url(r'^private-thread/(?P<thread_slug>[\w\d-]+)-(?P<thread_id>\d+)/post-(?P<post_id>\d+)/$', GotoPostView.as_view(), name='private_thread_post'),
+)
+
+
+from misago.threads.views.privatethreads import PostingView
+urlpatterns += patterns('',
+    url(r'^start-private-thread/$', PostingView.as_view(), name='start_private_thread'),
+    url(r'^reply-private-thread/(?P<thread_id>\d+)/$', PostingView.as_view(), name='reply_private_thread'),
 )

+ 46 - 5
misago/threads/views/privatethreads.py

@@ -1,10 +1,12 @@
 from django.utils.translation import ugettext as _
 
+from misago.acl import add_acl
 from misago.forums.models import Forum
 
 from misago.threads.permissions import (allow_use_private_threads,
                                         exclude_invisible_private_threads)
 from misago.threads.views import generic
+from misago.threads.views.posting import PostingView as BasePostingView
 
 
 class PrivateThreads(generic.Threads):
@@ -27,14 +29,53 @@ class PrivateThreadsFiltering(generic.ThreadsFiltering):
         return filters
 
 
-class PrivateThreadsView(generic.ThreadsView):
+def private_threads_view(klass):
+    def get_forum(self, request, lock=False, **kwargs):
+        forum = Forum.objects.private_threads()
+        add_acl(request.user, forum)
+        return forum
+
+    def dispatch(self, request, *args, **kwargs):
+        allow_use_private_threads(request.user)
+
+        return super(self.__class__, self).dispatch(
+            request, *args, **kwargs)
+
+    klass.get_forum = get_forum
+    klass.dispatch = dispatch
+
+    return klass
+
+
+@private_threads_view
+class ThreadsView(generic.ThreadsView):
     link_name = 'misago:private_threads'
     template = 'misago/privatethreads/list.html'
+
     Threads = PrivateThreads
     Filtering = PrivateThreadsFiltering
 
-    def dispatch(self, request, *args, **kwargs):
-        allow_use_private_threads(request.user)
 
-        return super(PrivateThreadsView, self).dispatch(
-            request, *args, **kwargs)
+@private_threads_view
+class ThreadView(generic.ThreadView):
+    pass
+
+
+@private_threads_view
+class GotoLastView(generic.GotoLastView):
+    pass
+
+
+@private_threads_view
+class GotoNewView(generic.GotoNewView):
+    pass
+
+
+@private_threads_view
+class GotoPostView(generic.GotoPostView):
+    pass
+
+
+@private_threads_view
+class PostingView(BasePostingView):
+    pass

+ 1 - 0
misago/users/urls.py

@@ -33,6 +33,7 @@ urlpatterns += patterns('misago.users.views.api',
     url(r'^api/validate/email/$', 'validate_email', name='api_validate_email'),
     url(r'^api/validate/email/(?P<user_id>\d+)/$', 'validate_email', name='api_validate_email'),
     url(r'^api/validate/password/$', 'validate_password', name='api_validate_password'),
+    url(r'^api/suggestion-engine/$', 'suggestion_engine', name='api_suggestion_engine'),
 )
 
 

+ 32 - 0
misago/users/views/api.py

@@ -1,5 +1,7 @@
+from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.core.exceptions import ValidationError
+from django.core.urlresolvers import reverse
 from django.http import JsonResponse
 from django.shortcuts import get_object_or_404
 from django.utils.translation import ugettext as _
@@ -8,6 +10,7 @@ from django.views.decorators.debug import sensitive_post_parameters
 from misago.core.decorators import ajax_only, require_POST
 
 from misago.users import validators
+from misago.users.decorators import deny_guests
 
 
 def api(f):
@@ -59,3 +62,32 @@ def validate_password(request):
         return _("Entered password is valid.")
     except KeyError:
         raise ValidationError(_('Enter password.'))
+
+
+@ajax_only
+@require_POST
+@deny_guests
+def suggestion_engine(request):
+    suggestions = []
+
+    username = request.POST.get('username', '').lower()
+    if len(username) > 1:
+        User = get_user_model()
+        queryset = User.objects.filter(slug__startswith=username)
+
+        for user in queryset.order_by('slug')[:5]:
+            avatars = {}
+            for size in settings.MISAGO_AVATARS_SIZES:
+                avatars[size] = reverse('misago:user_avatar', kwargs={
+                    'size': size, 'user_id': user.pk
+                })
+
+            suggestions.append({
+                'avatar': avatars,
+                'username': user.username,
+                'url': user.get_absolute_url()
+            })
+
+    return JsonResponse({
+        'profiles': suggestions
+    })

+ 2 - 1
misago/users/views/profile.py

@@ -108,10 +108,11 @@ def render(request, template, context):
 
     if request.user.is_authenticated():
         try:
+            allow_message_user(request.user, context['profile'])
             context['can_message'] = True
         except PermissionDenied as e:
             context['can_message'] = False
-            context['cant_message_reason'] = unicode(e)
+            context['cant_message_reason'] = e
 
     return django_render(request, template, context)