]> git.mxchange.org Git - quix0rs-gnu-social.git/commitdiff
Merge remote branch 'origin/1.0.x' into 1.0.x
authorEvan Prodromou <evan@status.net>
Wed, 16 Mar 2011 13:52:35 +0000 (09:52 -0400)
committerEvan Prodromou <evan@status.net>
Wed, 16 Mar 2011 13:52:35 +0000 (09:52 -0400)
50 files changed:
EVENTS.txt
actions/apigrouplist.php
actions/apitimelineuser.php
actions/noticesearch.php
actions/shownotice.php
classes/Notice.php
lib/action.php
lib/info.php
plugins/ExtendedProfile/ExtendedProfilePlugin.php
plugins/ExtendedProfile/Profile_detail.php
plugins/ExtendedProfile/action/userautocomplete.php [new file with mode: 0644]
plugins/ExtendedProfile/css/profiledetail.css [new file with mode: 0644]
plugins/ExtendedProfile/extendedprofile.php
plugins/ExtendedProfile/extendedprofilewidget.php
plugins/ExtendedProfile/js/profiledetail.js [new file with mode: 0644]
plugins/ExtendedProfile/profiledetail.css [deleted file]
plugins/ExtendedProfile/profiledetailaction.php
plugins/ExtendedProfile/profiledetailsettingsaction.php
plugins/Realtime/README
plugins/Realtime/RealtimePlugin.php
plugins/Realtime/realtimeupdate.js
plugins/Realtime/realtimeupdate.min.js
plugins/SearchSub/SearchSub.php [new file with mode: 0644]
plugins/SearchSub/SearchSubPlugin.php [new file with mode: 0644]
plugins/SearchSub/searchsubaction.php [new file with mode: 0644]
plugins/SearchSub/searchsubform.php [new file with mode: 0644]
plugins/SearchSub/searchunsubaction.php [new file with mode: 0644]
plugins/SearchSub/searchunsubform.php [new file with mode: 0644]
plugins/SubMirror/SubMirrorPlugin.php
plugins/SubMirror/actions/addmirror.php
plugins/SubMirror/actions/basemirror.php
plugins/SubMirror/actions/mirrorsettings.php
plugins/SubMirror/css/mirrorsettings.css [new file with mode: 0644]
plugins/SubMirror/images/providers/facebook.png [new file with mode: 0644]
plugins/SubMirror/images/providers/feed.png [new file with mode: 0644]
plugins/SubMirror/images/providers/linkedin.png [new file with mode: 0644]
plugins/SubMirror/images/providers/statusnet.png [new file with mode: 0644]
plugins/SubMirror/images/providers/twitter.png [new file with mode: 0644]
plugins/SubMirror/images/providers/wordpress.png [new file with mode: 0644]
plugins/SubMirror/js/mirrorsettings.js [new file with mode: 0644]
plugins/SubMirror/lib/addmirrorform.php
plugins/SubMirror/lib/addmirrorwizard.php [new file with mode: 0644]
plugins/SubMirror/lib/addtwittermirrorform.php [new file with mode: 0644]
plugins/TagSub/TagSub.php [new file with mode: 0644]
plugins/TagSub/TagSubPlugin.php [new file with mode: 0644]
plugins/TagSub/tagsubaction.php [new file with mode: 0644]
plugins/TagSub/tagsubform.php [new file with mode: 0644]
plugins/TagSub/tagunsubaction.php [new file with mode: 0644]
plugins/TagSub/tagunsubform.php [new file with mode: 0644]
theme/neo/css/display.css

index 1443a94fbed9dc4a96a591406ffab9d4f2af0fac..54d06655eecdcbb592ac307579bcd25e547e6e34 100644 (file)
@@ -1131,3 +1131,11 @@ StartActivityObjectOutputJson: Called at start of JSON output generation for Act
 EndActivityObjectOutputJson: Called at end of JSON output generation for ActivityObject chunks: the array has not yet been filled out.
 - $obj ActivityObject
 - &$out: array to be serialized; you're free to modify it
+
+StartNoticeWhoGets: Called at start of inbox delivery prep; plugins can schedule notices to go to particular profiles that would otherwise not have reached them. Canceling will take over the entire addressing operation. Be aware that output can be cached or used several times, so should remain idempotent.
+- $notice Notice
+- &$ni: in/out array mapping profile IDs to constants: NOTICE_INBOX_SOURCE_SUB etc
+
+EndNoticeWhoGets: Called at end of inbox delivery prep; plugins can filter out profiles from receiving inbox delivery here.  Be aware that output can be cached or used several times, so should remain idempotent.
+- $notice Notice
+- &$ni: in/out array mapping profile IDs to constants: NOTICE_INBOX_SOURCE_SUB etc
index 1f6d44363f6377bc496550fe95bd2469c9257eec..f80fbce932445e04b030220d49a6922ef17f0304 100644 (file)
@@ -100,7 +100,7 @@ class ApiGroupListAction extends ApiBareAuthAction
         );
 
         $subtitle   = sprintf(
-            // TRANS: Used as subtitle in check for group membership. %1$s is a user name, %2$s is the site name.
+            // TRANS: Used as subtitle in check for group membership. %1$s is the site name, %2$s is a user name.
             _('%1$s groups %2$s is a member of.'),
             $sitename,
             $this->user->nickname
index 66984b5abda1ca47ad8dafd338ca98c215d5237e..3fe73c691cf7bec1a329de3fa0f48297465d334d 100644 (file)
@@ -322,8 +322,11 @@ class ApiTimelineUserAction extends ApiBareAuthAction
             $this->clientError(_('Atom post must not be empty.'));
         }
 
-        $dom = DOMDocument::loadXML($xml);
-        if (!$dom) {
+        $old = error_reporting(error_reporting() & ~(E_WARNING | E_NOTICE));
+        $dom = new DOMDocument();
+        $ok = $dom->loadXML($xml);
+        error_reporting($old);
+        if (!$ok) {
             // TRANS: Client error displayed attempting to post an API that is not well-formed XML.
             $this->clientError(_('Atom post must be well-formed XML.'));
         }
index 4f4c7a05ba35fdd510cf4c9d69f465595b101926..1f43af800d2194ccdbfbba1b3541b21c4c86ef12 100644 (file)
@@ -138,11 +138,14 @@ class NoticesearchAction extends SearchAction
             $this->elementEnd('div');
             return;
         }
-        $terms = preg_split('/[\s,]+/', $q);
-        $nl = new SearchNoticeList($notice, $this, $terms);
-        $cnt = $nl->show();
-        $this->pagination($page > 1, $cnt > NOTICES_PER_PAGE,
-                          $page, 'noticesearch', array('q' => $q));
+        if (Event::handle('StartNoticeSearchShowResults', array($this, $q, $notice))) {
+            $terms = preg_split('/[\s,]+/', $q);
+            $nl = new SearchNoticeList($notice, $this, $terms);
+            $cnt = $nl->show();
+            $this->pagination($page > 1, $cnt > NOTICES_PER_PAGE,
+                              $page, 'noticesearch', array('q' => $q));
+            Event::handle('EndNoticeSearchShowResults', array($this, $q, $notice));
+        }
     }
 
     function showScripts()
index 3978f03ea9ce511cb69397980855c9d3c5ea2148..b8927372bb10d1c72e83da54ab66ec052d4944b3 100644 (file)
@@ -78,6 +78,9 @@ class ShownoticeAction extends OwnerDesignAction
     function prepare($args)
     {
         parent::prepare($args);
+        if ($this->boolean('ajax')) {
+            StatusNet::setApi(true);
+        }
 
         $id = $this->arg('notice');
 
@@ -188,22 +191,26 @@ class ShownoticeAction extends OwnerDesignAction
     {
         parent::handle($args);
 
-        if ($this->notice->is_local == Notice::REMOTE_OMB) {
-            if (!empty($this->notice->url)) {
-                $target = $this->notice->url;
-            } else if (!empty($this->notice->uri) && preg_match('/^https?:/', $this->notice->uri)) {
-                // Old OMB posts saved the remote URL only into the URI field.
-                $target = $this->notice->uri;
-            } else {
-                // Shouldn't happen.
-                $target = false;
-            }
-            if ($target && $target != $this->selfUrl()) {
-                common_redirect($target, 301);
-                return false;
+        if ($this->boolean('ajax')) {
+            $this->showAjax();
+        } else {
+            if ($this->notice->is_local == Notice::REMOTE_OMB) {
+                if (!empty($this->notice->url)) {
+                    $target = $this->notice->url;
+                } else if (!empty($this->notice->uri) && preg_match('/^https?:/', $this->notice->uri)) {
+                    // Old OMB posts saved the remote URL only into the URI field.
+                    $target = $this->notice->uri;
+                } else {
+                    // Shouldn't happen.
+                    $target = false;
+                }
+                if ($target && $target != $this->selfUrl()) {
+                    common_redirect($target, 301);
+                    return false;
+                }
             }
+            $this->showPage();
         }
-        $this->showPage();
     }
 
     /**
@@ -232,6 +239,21 @@ class ShownoticeAction extends OwnerDesignAction
         $this->elementEnd('ol');
     }
 
+    function showAjax()
+    {
+        header('Content-Type: text/xml;charset=utf-8');
+        $this->xw->startDocument('1.0', 'UTF-8');
+        $this->elementStart('html');
+        $this->elementStart('head');
+        $this->element('title', null, _('Notice'));
+        $this->elementEnd('head');
+        $this->elementStart('body');
+        $nli = new NoticeListItem($this->notice, $this);
+        $nli->show();
+        $this->elementEnd('body');
+        $this->elementEnd('html');
+    }
+
     /**
      * Don't show page notice
      *
index d520f4728f60d98758041aae7fce74a923a497a0..664e5dab9f85d3c4d51901a041e7638ef285ab2b 100644 (file)
@@ -812,41 +812,48 @@ class Notice extends Memcached_DataObject
 
         $ni = array();
 
-        foreach ($users as $id) {
-            $ni[$id] = NOTICE_INBOX_SOURCE_SUB;
-        }
+        // Give plugins a chance to add folks in at start...
+        if (Event::handle('StartNoticeWhoGets', array($this, &$ni))) {
 
-        foreach ($groups as $group) {
-            $users = $group->getUserMembers();
             foreach ($users as $id) {
-                if (!array_key_exists($id, $ni)) {
-                    $ni[$id] = NOTICE_INBOX_SOURCE_GROUP;
+                $ni[$id] = NOTICE_INBOX_SOURCE_SUB;
+            }
+
+            foreach ($groups as $group) {
+                $users = $group->getUserMembers();
+                foreach ($users as $id) {
+                    if (!array_key_exists($id, $ni)) {
+                        $ni[$id] = NOTICE_INBOX_SOURCE_GROUP;
+                    }
                 }
             }
-        }
 
-        foreach ($recipients as $recipient) {
-            if (!array_key_exists($recipient, $ni)) {
-                $ni[$recipient] = NOTICE_INBOX_SOURCE_REPLY;
+            foreach ($recipients as $recipient) {
+                if (!array_key_exists($recipient, $ni)) {
+                    $ni[$recipient] = NOTICE_INBOX_SOURCE_REPLY;
+                }
             }
-        }
 
-        // Exclude any deleted, non-local, or blocking recipients.
-        $profile = $this->getProfile();
-        $originalProfile = null;
-        if ($this->repeat_of) {
-            // Check blocks against the original notice's poster as well.
-            $original = Notice::staticGet('id', $this->repeat_of);
-            if ($original) {
-                $originalProfile = $original->getProfile();
+            // Exclude any deleted, non-local, or blocking recipients.
+            $profile = $this->getProfile();
+            $originalProfile = null;
+            if ($this->repeat_of) {
+                // Check blocks against the original notice's poster as well.
+                $original = Notice::staticGet('id', $this->repeat_of);
+                if ($original) {
+                    $originalProfile = $original->getProfile();
+                }
             }
-        }
-        foreach ($ni as $id => $source) {
-            $user = User::staticGet('id', $id);
-            if (empty($user) || $user->hasBlocked($profile) ||
-                ($originalProfile && $user->hasBlocked($originalProfile))) {
-                unset($ni[$id]);
+            foreach ($ni as $id => $source) {
+                $user = User::staticGet('id', $id);
+                if (empty($user) || $user->hasBlocked($profile) ||
+                    ($originalProfile && $user->hasBlocked($originalProfile))) {
+                    unset($ni[$id]);
+                }
             }
+
+            // Give plugins a chance to filter out...
+            Event::handle('EndNoticeWhoGets', array($this, &$ni));
         }
 
         if (!empty($c)) {
@@ -1999,6 +2006,11 @@ class Notice extends Memcached_DataObject
                 $this->is_local == Notice::LOCAL_NONPUBLIC);
     }
 
+    /**
+     * Get the list of hash tags saved with this notice.
+     *
+     * @return array of strings
+     */
     public function getTags()
     {
         $tags = array();
index 233ac7856788e05dd36dc668e319e57fa85cc0a1..fce59ba8a008f54583f400e7721ee4cbbc8d1c61 100644 (file)
@@ -681,6 +681,9 @@ class Action extends HTMLOutputter // lawsuit
     function showCore()
     {
         $this->elementStart('div', array('id' => 'core'));
+        $this->elementStart('div', array('id' => 'aside_primary_wrapper'));
+        $this->elementStart('div', array('id' => 'content_wrapper'));
+        $this->elementStart('div', array('id' => 'site_nav_local_views_wrapper'));
         if (Event::handle('StartShowLocalNavBlock', array($this))) {
             $this->showLocalNavBlock();
             Event::handle('EndShowLocalNavBlock', array($this));
@@ -694,6 +697,9 @@ class Action extends HTMLOutputter // lawsuit
             Event::handle('EndShowAside', array($this));
         }
         $this->elementEnd('div');
+        $this->elementEnd('div');
+        $this->elementEnd('div');
+        $this->elementEnd('div');
     }
 
     /**
index 395c6522ec888d72476d785e194475e32641f8f0..f72bed59d62a437b3714c7807c7c9ad357cc5775 100644 (file)
@@ -93,8 +93,14 @@ class InfoAction extends Action
     function showCore()
     {
         $this->elementStart('div', array('id' => 'core'));
+        $this->elementStart('div', array('id' => 'aside_primary_wrapper'));
+        $this->elementStart('div', array('id' => 'content_wrapper'));
+        $this->elementStart('div', array('id' => 'site_nav_local_views_wrapper'));
         $this->showContentBlock();
         $this->elementEnd('div');
+        $this->elementEnd('div');
+        $this->elementEnd('div');
+        $this->elementEnd('div');
     }
 
     function showHeader()
index 3f541c000835868b7a666fef391022dd9be4fd5e..d1572ce9fdcc3bd286ad7845ce43fe4fbd30df6b 100644 (file)
@@ -54,6 +54,7 @@ class ExtendedProfilePlugin extends Plugin
     function onAutoload($cls)
     {
         $lower = strtolower($cls);
+
         switch ($lower)
         {
         case 'extendedprofile':
@@ -62,6 +63,9 @@ class ExtendedProfilePlugin extends Plugin
         case 'profiledetailsettingsaction':
             require_once dirname(__FILE__) . '/' . $lower . '.php';
             return false;
+        case 'userautocompleteaction':
+            require_once dirname(__FILE__) . '/action/' . mb_substr($lower, 0, -6) . '.php';
+            return false;
         case 'profile_detail':
             require_once dirname(__FILE__) . '/' . ucfirst($lower) . '.php';
             return false;
@@ -81,11 +85,19 @@ class ExtendedProfilePlugin extends Plugin
      */
     function onStartInitializeRouter($m)
     {
-        $m->connect(':nickname/detail',
-                array('action' => 'profiledetail'),
-                array('nickname' => Nickname::DISPLAY_FMT));
-        $m->connect('settings/profile/detail',
-                array('action' => 'profiledetailsettings'));
+        $m->connect(
+            ':nickname/detail',
+            array('action' => 'profiledetail'),
+            array('nickname' => Nickname::DISPLAY_FMT)
+        );
+        $m->connect(
+            '/settings/profile/finduser',
+            array('action' => 'Userautocomplete')
+        );
+        $m->connect(
+            'settings/profile/detail',
+            array('action' => 'profiledetailsettings')
+        );
 
         return true;
     }
@@ -95,8 +107,6 @@ class ExtendedProfilePlugin extends Plugin
         $schema = Schema::get();
         $schema->ensureTable('profile_detail', Profile_detail::schemaDef());
 
-        // @hack until key definition support is merged
-        Profile_detail::fixIndexes($schema);
         return true;
     }
 
index 6fd96cca706f2dafde444cc5f02c0ecd06d7b537..96869b0e63bc252cc0452184d89180794a9739f3 100644 (file)
@@ -21,130 +21,122 @@ if (!defined('STATUSNET')) {
     exit(1);
 }
 
-class Profile_detail extends Memcached_DataObject
+/**
+ * DataObject class to store extended profile fields. Allows for storing
+ * multiple values per a "field_name" (field_name property is not unique).
+ *
+ * Example:
+ *
+ *     Jed's Phone Numbers
+ *     home  : 510-384-1992
+ *     mobile: 510-719-1139
+ *     work  : 415-231-1121
+ *
+ * We can store these phone numbers in a "field" represented by three
+ * Profile_detail objects, each named 'phone_number' like this:
+ *
+ *     $phone1 = new Profile_detail();
+ *     $phone1->field_name  = 'phone_number';
+ *     $phone1->rel         = 'home';
+ *     $phone1->field_value = '510-384-1992';
+ *     $phone1->value_index = 1;
+ *
+ *     $phone1 = new Profile_detail();
+ *     $phone1->field_name  = 'phone_number';
+ *     $phone1->rel         = 'mobile';
+ *     $phone1->field_value = '510-719-1139';
+ *     $phone1->value_index = 2;
+ *
+ *     $phone1 = new Profile_detail();
+ *     $phone1->field_name  = 'phone_number';
+ *     $phone1->rel         = 'work';
+ *     $phone1->field_value = '415-231-1121';
+ *     $phone1->value_index = 3;
+ *
+ */
+class Profile_detail extends Managed_DataObject
 {
-    public $__table = 'submirror';
+    public $__table = 'profile_detail';
 
     public $id;
-
-    public $profile_id;
-    public $field;
-    public $field_index; // relative ordering of multiple values in the same field
-
-    public $value; // primary text value
-    public $rel; // detail for some field types; eg "home", "mobile", "work" for phones or "aim", "irc", "xmpp" for IM
+    public $profile_id;  // profile this is for
+    public $rel;         // detail for some field types; eg "home", "mobile", "work" for phones or "aim", "irc", "xmpp" for IM
+    public $field_name;  // name
+    public $field_value; // primary text value
+    public $value_index; // relative ordering of multiple values in the same field
+    public $date;        // related date
     public $ref_profile; // for people types, allows pointing to a known profile in the system
-
     public $created;
     public $modified;
 
-    public /*static*/ function staticGet($k, $v=null)
-    {
-        return parent::staticGet(__CLASS__, $k, $v);
-    }
-
     /**
-     * return table definition for DB_DataObject
+     * Get an instance by key
      *
-     * DB_DataObject needs to know something about the table to manipulate
-     * instances. This method provides all the DB_DataObject needs to know.
+     * This is a utility method to get a single instance with a given key value.
      *
-     * @return array array of column definitions
-     */
-
-    function table()
-    {
-        return array('id' =>  DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL,
-
-                     'profile_id' => DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL,
-                     'field' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
-                     'field_index' => DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL,
-
-                     'value' => DB_DATAOBJECT_STR,
-                     'rel' => DB_DATAOBJECT_STR,
-                     'ref_profile' => DB_DATAOBJECT_ID,
-
-                     'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL,
-                     'modified' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL);
-    }
-
-    static function schemaDef()
-    {
-        // @fixme need a reverse key on (subscribed, subscriber) as well
-        return array(new ColumnDef('id', 'integer',
-                                   null, false, 'PRI'),
-
-                     // @fixme need a unique index on these three
-                     new ColumnDef('profile_id', 'integer',
-                                   null, false),
-                     new ColumnDef('field', 'varchar',
-                                   16, false),
-                     new ColumnDef('field_index', 'integer',
-                                   null, false),
-
-                     new ColumnDef('value', 'text',
-                                   null, true),
-                     new ColumnDef('rel', 'varchar',
-                                   16, true),
-                     new ColumnDef('ref_profile', 'integer',
-                                   null, true),
-
-                     new ColumnDef('created', 'datetime',
-                                   null, false),
-                     new ColumnDef('modified', 'datetime',
-                                   null, false));
-    }
-
-    /**
-     * Temporary hack to set up the compound index, since we can't do
-     * it yet through regular Schema interface. (Coming for 1.0...)
+     * @param string $k Key to use to lookup
+     * @param mixed  $v Value to lookup
      *
-     * @param Schema $schema
-     * @return void
-     */
-    static function fixIndexes($schema)
-    {
-        try {
-            // @fixme this won't be a unique index... SIGH
-            $schema->createIndex('profile_detail', array('profile_id', 'field', 'field_index'));
-        } catch (Exception $e) {
-            common_log(LOG_ERR, __METHOD__ . ': ' . $e->getMessage());
-        }
-    }
-
-    /**
-     * return key definitions for DB_DataObject
+     * @return User_greeting_count object found, or null for no hits
      *
-     * DB_DataObject needs to know about keys that the table has; this function
-     * defines them.
-     *
-     * @return array key definitions
      */
 
-    function keys()
+    function staticGet($k, $v=null)
     {
-        return array_keys($this->keyTypes());
+        return Memcached_DataObject::staticGet('Profile_detail', $k, $v);
     }
 
     /**
-     * return key definitions for Memcached_DataObject
+     * Get an instance by compound key
+     *
+     * This is a utility method to get a single instance with a given set of
+     * key-value pairs. Usually used for the primary key for a compound key; thus
+     * the name.
      *
-     * Our caching system uses the same key definitions, but uses a different
-     * method to get them.
+     * @param array $kv array of key-value mappings
+     *
+     * @return Bookmark object found, or null for no hits
      *
-     * @return array key definitions
      */
 
-    function keyTypes()
+    function pkeyGet($kv)
     {
-        // @fixme keys
-        // need a sane key for reverse lookup too
-        return array('id' => 'K');
+        return Memcached_DataObject::pkeyGet('Profile_detail', $kv);
     }
 
-    function sequenceKey()
+    static function schemaDef()
     {
-        return array('id', true);
+        return array(
+            'description'
+                => 'Additional profile details for the ExtendedProfile plugin',
+            'fields'      => array(
+                'id'          => array('type' => 'serial', 'not null' => true),
+                'profile_id'  => array('type' => 'int', 'not null' => true),
+                'field_name'  => array(
+                    'type'     => 'varchar',
+                    'length'   => 16,
+                    'not null' => true
+                ),
+                'value_index' => array('type' => 'int'),
+                'field_value' => array('type' => 'text'),
+                'date'        => array('type' => 'datetime'),
+                'rel'         => array('type' => 'varchar', 'length' => 16),
+                'rel_profile' => array('type' => 'int'),
+                'created'     => array(
+                    'type'     => 'datetime',
+                    'not null' => true
+                 ),
+                'modified'    => array(
+                    'type' => 'timestamp',
+                    'not null' => true
+                ),
+            ),
+            'primary key' => array('id'),
+            'unique keys' => array(
+                'profile_detail_profile_id_field_name_value_index'
+                    => array('profile_id', 'field_name', 'value_index'),
+            )
+        );
     }
 
 }
diff --git a/plugins/ExtendedProfile/action/userautocomplete.php b/plugins/ExtendedProfile/action/userautocomplete.php
new file mode 100644 (file)
index 0000000..d485742
--- /dev/null
@@ -0,0 +1,113 @@
+<?php
+/**
+ * StatusNet, the distributed open-source microblogging tool
+ *
+ * Action for showing Twitter-like JSON search results
+ *
+ * PHP version 5
+ *
+ * LICENCE: This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * @category  Search
+ * @package   StatusNet
+ * @author    Zach Copley <zach@status.net>
+ * @copyright 2011 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
+ * @link      http://status.net/
+ */
+
+if (!defined('STATUSNET')) {
+    exit(1);
+}
+
+
+class UserautocompleteAction extends Action
+{
+    var $query;
+
+    /**
+     * Initialization.
+     *
+     * @param array $args Web and URL arguments
+     *
+     * @return boolean true if nothing goes wrong
+     */
+    function prepare($args)
+    {
+        parent::prepare($args);
+        $this->query = $this->trimmed('term');
+        return true;
+    }
+
+    /**
+     * Handle a request
+     *
+     * @param array $args Arguments from $_REQUEST
+     *
+     * @return void
+     */
+    function handle($args)
+    {
+        parent::handle($args);
+        $this->showResults();
+    }
+
+    /**
+     * Search for users matching the query and spit the results out
+     * as a quick-n-dirty JSON document
+     *
+     * @return void
+     */
+    function showResults()
+    {
+        $people = array();
+
+        $profile = new Profile();
+
+        $search_engine = $profile->getSearchEngine('profile');
+        $search_engine->set_sort_mode('nickname_desc');
+        $search_engine->limit(0, 10);
+        $search_engine->query(strtolower($this->query . '*'));
+
+        $cnt = $profile->find();
+
+        if ($cnt > 0) {
+
+            $sql = 'SELECT profile.* FROM profile, user WHERE profile.id = user.id '
+                . ' AND LEFT(LOWER(profile.nickname), '
+                . strlen($this->query)
+                . ') = \'%s\' '
+                . ' LIMIT 0, 10';
+
+            $profile->query(sprintf($sql, $this->query));
+        }
+        
+        while ($profile->fetch()) {
+             $people[] = $profile->nickname;
+        }
+
+        header('Content-Type: application/json; charset=utf-8');
+        print json_encode($people);
+    }
+
+    /**
+     * Do we need to write to the database?
+     *
+     * @return boolean true
+     */
+    function isReadOnly($args)
+    {
+        return true;
+    }
+}
diff --git a/plugins/ExtendedProfile/css/profiledetail.css b/plugins/ExtendedProfile/css/profiledetail.css
new file mode 100644 (file)
index 0000000..3af9bcb
--- /dev/null
@@ -0,0 +1,164 @@
+/* Note the #content is only needed to override weird crap in default styles */
+
+#profiledetail .entity_actions {
+    margin-top: 0px;
+    margin-bottom: 0px;
+}
+
+#profiledetail #content h3 {
+    margin-bottom: 5px;
+}
+
+#content table.extended-profile {
+    width: 100%;
+    border-collapse: separate;
+    border-spacing: 0px 8px;
+    margin-bottom: 10px;
+}
+
+#content table.extended-profile th {
+    color: #777;
+    background-color: #ECECF2;
+    width: 150px;
+    text-align: right;
+    padding: 2px 8px 2px 0px;
+}
+
+#content table.extended-profile th.employer, #content table.extended-profile th.institution {
+    display: none;
+}
+
+#content table.extended-profile td {
+    padding: 2px 0px 2px 8px;        
+}
+
+.experience-item, .education-item {
+    float: left;
+    padding-bottom: 4px;
+}
+
+.experience-item .label, .education-item .label {
+    float: left;
+    clear: left;
+    position: relative;
+    left: -8px;
+    margin-right: 2px;
+    margin-bottom: 8px;
+    color: #777;
+    background-color: #ECECF2;
+    width: 150px;
+    text-align: right;
+    padding: 2px 8px 2px 0px;
+}
+
+.experience-item .field, .education-item .field {
+    float: left;
+    padding-top: 2px;
+    padding-bottom: 2px;
+}
+
+#profiledetailsettings #content table.extended-profile td {
+    padding: 0px 0px 0px 8px;
+}
+
+#profiledetailsettings input {
+    margin-right: 8px;
+}
+
+.form_settings .extended-profile label {
+    display: none;
+}
+
+.extended-profile textarea {
+    width: 280px;
+}
+
+.extended-profile input[type=text] {
+    width: 280px;
+}
+
+.extended-profile .phone-item input[type=text], .extended-profile .im-item input[type=text], .extended-profile .website-item input[type=text] {
+    width: 175px;
+}
+
+.extended-profile input.hasDatepicker {
+    width: 100px;
+}
+
+.experience-item input[type=text], .education-item input[type=text] {
+    float: left;
+}
+
+.extended-profile .current-checkbox {
+    float: left;
+    position: relative;
+    top: 2px;
+}
+
+.form_settings .extended-profile input.checkbox {
+    margin-left: 0px;
+    left: 0px;
+    top: 2px;
+}
+
+.form_settings .extended-profile label.checkbox {
+    max-width: 100%;
+    float: none;
+    display: inline;
+    left: -20px;
+}
+
+.extended-profile select {
+    padding-right: 2px;
+    font-size: 0.88em;
+}
+
+.extended-profile a.add_row, .extended-profile a.remove_row {
+    display: block;
+    height: 16px;
+    width: 16px;
+    overflow: hidden;
+    background-image: url('../../../theme/rebase/images/icons/icons-01.gif');
+    background-repeat: no-repeat;
+}
+
+.extended-profile a.remove_row {
+    background-position: 0px -1252px;
+    float: right;
+    position: relative;
+    top: 6px;
+    line-height: 4em;
+}
+
+.extended-profile a.add_row {
+    clear: both;
+    position: relative;
+    top: 6px;
+    left: 2px; 
+    background-position: 0px -1186px;
+    width: 120px;
+    padding-left: 20px;
+    line-height: 1.2em;
+}
+
+#content table.extended-profile .supersizeme th {
+    border-bottom: 28px solid #fff;
+}
+
+#profiledetailsettings .experience-item, #profiledetailsettings .education-item {
+    margin-bottom: 10px;
+    width: 100%;
+}
+
+#profiledetailsettings .education-item textarea {
+    float: left;
+    margin-bottom: 8px;
+}
+
+#profiledetailsettings tr:last-child .experience-item, #profiledetailsettings tr:last-child .education-item {
+    margin-bottom: 0px;
+}
+
+#profiledetailsettings .experience-item a.add_row, #profiledetailsettings .education-item a.add_row {
+    left: 160px;
+}
index 7f69f90899237e479cb8e09d276352a41d4d5343..fa632e5073886b9eca4fe2664db1329516049345 100644 (file)
@@ -21,27 +21,256 @@ if (!defined('STATUSNET')) {
     exit(1);
 }
 
+/**
+ * Class to represent extended profile data
+ */
 class ExtendedProfile
 {
+    protected $fields;
+
+    /**
+     * Constructor
+     *
+     * @param Profile $profile
+     */
     function __construct(Profile $profile)
     {
-        $this->profile = $profile;
+        $this->profile  = $profile;
+        $this->user     = $profile->getUser();
+        $this->fields   = $this->loadFields();
         $this->sections = $this->getSections();
-        $this->fields = $this->loadFields();
+        //common_debug(var_export($this->sections, true));
+
+        //common_debug(var_export($this->fields, true));
     }
 
+    /**
+     * Load extended profile fields
+     *
+     * @return array $fields the list of fields
+     */
     function loadFields()
     {
         $detail = new Profile_detail();
         $detail->profile_id = $this->profile->id;
         $detail->find();
-        
-        while ($detail->get()) {
-            $fields[$detail->field][] = clone($detail);
+
+        $fields = array();
+
+        while ($detail->fetch()) {
+            $fields[$detail->field_name][] = clone($detail);
         }
+
         return $fields;
     }
 
+    /**
+     * Get a the self-tags associated with this profile
+     *
+     * @return string the concatenated string of tags
+     */
+    function getTags()
+    {
+        return implode(' ', $this->user->getSelfTags());
+    }
+
+    /**
+     * Return a simple string value. Checks for fields that should
+     * be stored in the regular profile and returns values from it
+     * if appropriate.
+     *
+     * @param string $name name of the detail field to get the
+     *                     value from
+     *
+     * @return string the value
+     */
+    function getTextValue($name)
+    {
+        $key           = strtolower($name);
+        $profileFields = array('fullname', 'location', 'bio');
+
+        if (in_array($key, $profileFields)) {
+            return $this->profile->$name;
+        } else if (array_key_exists($key, $this->fields)) {
+            return $this->fields[$key][0]->field_value;
+        } else {
+            return null;
+        }
+    }
+
+    function getDateValue($name) {
+        $key = strtolower($name);
+        if (array_key_exists($key, $this->fields)) {
+            return $this->fields[$key][0]->date;
+        } else {
+            return null;
+        }
+    }
+
+    // XXX: getPhones, getIms, and getWebsites pretty much do the same thing,
+    //      so refactor.
+    function getPhones()
+    {
+        $phones = (isset($this->fields['phone'])) ? $this->fields['phone'] : null;
+        $pArrays = array();
+
+        if (empty($phones)) {
+            $pArrays[] = array(
+                'label' => _m('Phone'),
+                'index' => 0,
+                'type'  => 'phone',
+                'vcard' => 'tel',
+                'rel'   => 'office',
+                'value' => null
+            );
+        } else {
+            for ($i = 0; $i < sizeof($phones); $i++) {
+                $pa = array(
+                    'label' => _m('Phone'),
+                    'type'  => 'phone',
+                    'index' => intval($phones[$i]->value_index),
+                    'rel'   => $phones[$i]->rel,
+                    'value' => $phones[$i]->field_value,
+                    'vcard' => 'tel'
+                );
+
+               $pArrays[] = $pa;
+            }
+        }
+        return $pArrays;
+    }
+
+    function getIms()
+    {
+        $ims = (isset($this->fields['im'])) ? $this->fields['im'] : null;
+        $iArrays = array();
+
+        if (empty($ims)) {
+            $iArrays[] = array(
+                'label' => _m('IM'),
+                'type' => 'im'
+            );
+        } else {
+            for ($i = 0; $i < sizeof($ims); $i++) {
+                $ia = array(
+                    'label' => _m('IM'),
+                    'type'  => 'im',
+                    'index' => intval($ims[$i]->value_index),
+                    'rel'   => $ims[$i]->rel,
+                    'value' => $ims[$i]->field_value,
+                );
+
+                $iArrays[] = $ia;
+            }
+        }
+        return $iArrays;
+    }
+
+    function getWebsites()
+    {
+        $sites = (isset($this->fields['website'])) ? $this->fields['website'] : null;
+        $wArrays = array();
+
+        if (empty($sites)) {
+            $wArrays[] = array(
+                'label' => _m('Website'),
+                'type' => 'website'
+            );
+        } else {
+            for ($i = 0; $i < sizeof($sites); $i++) {
+                $wa = array(
+                    'label' => _m('Website'),
+                    'type'  => 'website',
+                    'index' => intval($sites[$i]->value_index),
+                    'rel'   => $sites[$i]->rel,
+                    'value' => $sites[$i]->field_value,
+                );
+
+                $wArrays[] = $wa;
+            }
+        }
+        return $wArrays;
+    }
+
+    function getExperiences()
+    {
+        $companies = (isset($this->fields['company'])) ? $this->fields['company'] : null;
+        $start = (isset($this->fields['start'])) ? $this->fields['start'] : null;
+        $end   = (isset($this->fields['end'])) ? $this->fields['end'] : null;
+
+        $eArrays = array();
+
+        if (empty($companies)) {
+            $eArrays[] = array(
+                'label'   => _m('Employer'),
+                'type'    => 'experience',
+                'company' => null,
+                'start'   => null,
+                'end'     => null,
+                'current' => false,
+                'index'   => 0
+            );
+        } else {
+            for ($i = 0; $i < sizeof($companies); $i++) {
+                $ea = array(
+                    'label'   => _m('Employer'),
+                    'type'    => 'experience',
+                    'company' => $companies[$i]->field_value,
+                    'index'   => intval($companies[$i]->value_index),
+                    'current' => $end[$i]->rel,
+                    'start'   => $start[$i]->date,
+                    'end'     => $end[$i]->date
+                );
+               $eArrays[] = $ea;
+            }
+        }
+        return $eArrays;
+    }
+
+    function getEducation()
+    {
+        $schools = (isset($this->fields['school'])) ? $this->fields['school'] : null;
+        $degrees = (isset($this->fields['degree'])) ? $this->fields['degree'] : null;
+        $descs = (isset($this->fields['degree_descr'])) ? $this->fields['degree_descr'] : null;
+        $start = (isset($this->fields['school_start'])) ? $this->fields['school_start'] : null;
+        $end = (isset($this->fields['school_end'])) ? $this->fields['school_end'] : null;
+        $iArrays = array();
+
+        if (empty($schools)) {
+            $iArrays[] = array(
+                'type' => 'education',
+                'label' => _m('Institution'),
+                'school' => null,
+                'degree' => null,
+                'description' => null,
+                'start' => null,
+                'end' => null,
+                'index' => 0
+            );
+        } else {
+            for ($i = 0; $i < sizeof($schools); $i++) {
+                $ia = array(
+                    'type'    => 'education',
+                    'label'   => _m('Institution'),
+                    'school'  => $schools[$i]->field_value,
+                    'degree'  => isset($degrees[$i]->field_value) ? $degrees[$i]->field_value : null,
+                    'description' => isset($descs[$i]->field_value) ? $descs[$i]->field_value : null,
+                    'index'   => intval($schools[$i]->value_index),
+                    'start'   => $start[$i]->date,
+                    'end'     => $end[$i]->date
+                );
+               $iArrays[] = $ia;
+            }
+        }
+
+        return $iArrays;
+    }
+
+    /**
+     *  Return all the sections of the extended profile
+     *
+     * @return array the big list of sections and fields
+     */
     function getSections()
     {
         return array(
@@ -81,22 +310,9 @@ class ExtendedProfile
             'contact' => array(
                 'label' => _m('Contact'),
                 'fields' => array(
-                    'phone' => array(
-                        'label' => _m('Phone'),
-                        'type' => 'phone',
-                        'multi' => true,
-                        'vcard' => 'tel',
-                    ),
-                    'im' => array(
-                        'label' => _m('IM'),
-                        'type' => 'im',
-                        'multi' => true,
-                    ),
-                    'website' => array(
-                        'label' => _m('Websites'),
-                        'type' => 'website',
-                        'multi' => true,
-                    ),
+                    'phone'   => $this->getPhones(),
+                    'im'      => $this->getIms(),
+                    'website' => $this->getWebsites()
                 ),
             ),
             'personal' => array(
@@ -119,19 +335,13 @@ class ExtendedProfile
             'experience' => array(
                 'label' => _m('Work experience'),
                 'fields' => array(
-                    'experience' => array(
-                        'type' => 'experience',
-                        'label' => _m('Employer'),
-                    ),
+                    'experience' => $this->getExperiences()
                 ),
             ),
             'education' => array(
                 'label' => _m('Education'),
                 'fields' => array(
-                    'education' => array(
-                        'type' => 'education',
-                        'label' => _m('Institution'),
-                    ),
+                    'education' => $this->getEducation()
                 ),
             ),
         );
index bf9b4056cd2177bff8367b4c6cb5ba1e48456400..1ef6440ed69bc1d3932821eba870866b49c8efed 100644 (file)
@@ -21,13 +21,35 @@ if (!defined('STATUSNET')) {
     exit(1);
 }
 
-class ExtendedProfileWidget extends Widget
+/**
+ * Class for outputting a widget to display or edit
+ * extended profiles
+ */
+class ExtendedProfileWidget extends Form
 {
-    const EDITABLE=true;
+    const EDITABLE = true;
 
+    /**
+     * The parent profile
+     *
+     * @var Profile
+     */
     protected $profile;
+
+    /**
+     * The extended profile
+     *
+     * @var Extended_profile
+     */
     protected $ext;
 
+    /**
+     * Constructor
+     *
+     * @param XMLOutputter  $out
+     * @param Profile       $profile
+     * @param boolean       $editable
+     */
     public function __construct(XMLOutputter $out=null, Profile $profile=null, $editable=false)
     {
         parent::__construct($out);
@@ -38,7 +60,37 @@ class ExtendedProfileWidget extends Widget
         $this->editable = $editable;
     }
 
+    /**
+     * Show the extended profile, or the edit form
+     */
     public function show()
+    {
+        if ($this->editable) {
+            parent::show();
+        } else {
+            $this->showSections();
+        }
+    }
+
+    /**
+     * Show form data
+     */
+    public function formData()
+    {
+        // For JQuery UI modal dialog
+        $this->out->elementStart(
+            'div',
+            array('id' => 'confirm-dialog', 'title' => 'Confirmation Required')
+        );
+        $this->out->text('Really delete this entry?');
+        $this->out->elementEnd('div');
+        $this->showSections();
+    }
+
+    /**
+     * Show each section of the extended profile
+     */
+    public function showSections()
     {
         $sections = $this->ext->getSections();
         foreach ($sections as $name => $section) {
@@ -46,21 +98,45 @@ class ExtendedProfileWidget extends Widget
         }
     }
 
+    /**
+     * Show an extended profile section
+     *
+     * @param string $name      name of the section
+     * @param array  $section   array of fields for the section
+     */
     protected function showExtendedProfileSection($name, $section)
     {
         $this->out->element('h3', null, $section['label']);
         $this->out->elementStart('table', array('class' => 'extended-profile'));
+
         foreach ($section['fields'] as $fieldName => $field) {
-            $this->showExtendedProfileField($fieldName, $field);
+
+            switch($fieldName) {
+            case 'phone':
+            case 'im':
+            case 'website':
+            case 'experience':
+            case 'education':
+                $this->showMultiple($fieldName, $field);
+                break;
+            default:
+                $this->showExtendedProfileField($fieldName, $field);
+            }
         }
         $this->out->elementEnd('table');
     }
 
+    /**
+     * Show an extended profile field
+     *
+     * @param string $name  name of the field
+     * @param array  $field set of key/value pairs for the field
+     */
     protected function showExtendedProfileField($name, $field)
     {
         $this->out->elementStart('tr');
 
-        $this->out->element('th', null, $field['label']);
+        $this->out->element('th', str_replace(' ','_',strtolower($field['label'])), $field['label']);
 
         $this->out->elementStart('td');
         if ($this->editable) {
@@ -73,30 +149,504 @@ class ExtendedProfileWidget extends Widget
         $this->out->elementEnd('tr');
     }
 
+    protected function showMultiple($name, $fields) {
+        foreach ($fields as $field) {
+            $this->showExtendedProfileField($name, $field);
+        }
+    }
+
+    // XXX: showPhone, showIm and showWebsite all work the same, so
+    //      combine
+    protected function showPhone($name, $field)
+    {
+        $this->out->elementStart('div', array('class' => 'phone-display'));
+        $this->out->text($field['value']);
+        if (!empty($field['rel'])) {
+            $this->out->text(' (' . $field['rel'] . ')');
+        }
+        $this->out->elementEnd('div');
+    }
+
+    protected function showIm($name, $field)
+    {
+        $this->out->elementStart('div', array('class' => 'im-display'));
+        $this->out->text($field['value']);
+        if (!empty($field['rel'])) {
+            $this->out->text(' (' . $field['rel'] . ')');
+        }
+        $this->out->elementEnd('div');
+    }
+
+    protected function showWebsite($name, $field)
+    {
+        $this->out->elementStart('div', array('class' => 'website-display'));
+
+        $url = $field['value'];
+
+        $this->out->element(
+            "a",
+            array(
+                'href'   => $url,
+                'class'  => 'extended-profile-link',
+                'target' => "_blank"
+            ),
+            $url
+        );
+
+        if (!empty($field['rel'])) {
+            $this->out->text(' (' . $field['rel'] . ')');
+        }
+        $this->out->elementEnd('div');
+    }
+
+    protected function showEditableIm($name, $field)
+    {
+        $index = isset($field['index']) ? $field['index'] : 0;
+        $id    = "extprofile-$name-$index";
+        $rel   = $id . '-rel';
+        $this->out->elementStart(
+            'div', array(
+                'id' => $id . '-edit',
+                'class' => 'im-item'
+            )
+        );
+        $this->out->input(
+            $id,
+            null,
+            isset($field['value']) ? $field['value'] : null
+        );
+        $this->out->dropdown(
+            $id . '-rel',
+            'Type',
+            array(
+                'jabber' => 'Jabber',
+                'gtalk'  => 'GTalk',
+                'aim'    => 'AIM',
+                'yahoo'  => 'Yahoo! Messenger',
+                'msn'    => 'MSN',
+                'skype'  => 'Skype',
+                'other'  => 'Other'
+            ),
+            null,
+            false,
+            isset($field['rel']) ? $field['rel'] : null
+        );
+
+        $this->showMultiControls();
+        $this->out->elementEnd('div');
+    }
+
+    protected function showEditablePhone($name, $field)
+    {
+        $index = isset($field['index']) ? $field['index'] : 0;
+        $id    = "extprofile-$name-$index";
+        $rel   = $id . '-rel';
+        $this->out->elementStart(
+            'div', array(
+                'id' => $id . '-edit',
+                'class' => 'phone-item'
+            )
+        );
+        $this->out->input(
+            $id,
+            null,
+            isset($field['value']) ? $field['value'] : null
+        );
+        $this->out->dropdown(
+            $id . '-rel',
+            'Type',
+            array(
+                'office' => 'Office',
+                'mobile' => 'Mobile',
+                'home'   => 'Home',
+                'pager'  => 'Pager',
+                'other'  => 'Other'
+            ),
+            null,
+            false,
+            isset($field['rel']) ? $field['rel'] : null
+        );
+
+        $this->showMultiControls();
+        $this->out->elementEnd('div');
+    }
+
+    protected function showEditableWebsite($name, $field)
+    {
+        $index = isset($field['index']) ? $field['index'] : 0;
+        $id    = "extprofile-$name-$index";
+        $rel   = $id . '-rel';
+        $this->out->elementStart(
+            'div', array(
+                'id' => $id . '-edit',
+                'class' => 'website-item'
+            )
+        );
+        $this->out->input(
+            $id,
+            null,
+            isset($field['value']) ? $field['value'] : null
+        );
+        $this->out->dropdown(
+            $id . '-rel',
+            'Type',
+            array(
+                'blog'     => 'Blog',
+                'homepage' => 'Homepage',
+                'facebook' => 'Facebook',
+                'linkedin' => 'LinkedIn',
+                'flickr'   => 'Flickr',
+                'google'   => 'Google Profile',
+                'other'    => 'Other',
+                'twitter'  => 'Twitter'
+            ),
+            null,
+            false,
+            isset($field['rel']) ? $field['rel'] : null
+        );
+
+        $this->showMultiControls();
+        $this->out->elementEnd('div');
+    }
+
+    protected function showExperience($name, $field)
+    {
+        $this->out->elementStart('div', 'experience-item');
+        $this->out->element('div', 'label', _m('Company'));
+
+        if (!empty($field['company'])) {
+            $this->out->element('div', 'field', $field['company']);
+
+            $this->out->element('div', 'label', _m('Start'));
+            $this->out->element(
+                'div',
+                array('class' => 'field date'),
+                date('j M Y', strtotime($field['start'])
+                )
+            );
+            $this->out->element('div', 'label', _m('End'));
+            $this->out->element(
+                'div',
+                array('class' => 'field date'),
+                date('j M Y', strtotime($field['end'])
+                )
+            );
+
+            if ($field['current']) {
+                $this->out->element(
+                    'div',
+                    array('class' => 'field current'),
+                    '(' . _m('Current') . ')'
+                );
+            }
+        }
+        $this->out->elementEnd('div');
+    }
+
+    protected function showEditableExperience($name, $field)
+    {
+        $index = isset($field['index']) ? $field['index'] : 0;
+        $id    = "extprofile-$name-$index";
+        $this->out->elementStart(
+            'div', array(
+                'id' => $id . '-edit',
+                'class' => 'experience-item'
+            )
+        );
+
+        $this->out->element('div', 'label', _m('Company'));
+        $this->out->input(
+            $id,
+            null,
+            isset($field['company']) ? $field['company'] : null
+        );
+
+        $this->out->element('div', 'label', _m('Start'));
+        $this->out->input(
+            $id . '-start',
+            null,
+            isset($field['start']) ? date('j M Y', strtotime($field['start'])) : null
+        );
+
+        $this->out->element('div', 'label', _m('End'));
+
+        $this->out->input(
+            $id . '-end',
+            null,
+            isset($field['end']) ? date('j M Y', strtotime($field['end'])) : null
+        );
+        $this->out->hidden(
+            $id . '-current',
+            'false'
+        );
+        $this->out->elementStart('div', 'current-checkbox');
+        $this->out->checkbox(
+            $id . '-current',
+            _m('Current'),
+            $field['current']
+        );
+        $this->out->elementEnd('div');
+
+        $this->showMultiControls();
+        $this->out->elementEnd('div');
+    }
+
+    protected function showEducation($name, $field)
+    {
+        $this->out->elementStart('div', 'education-item');
+        $this->out->element('div', 'label', _m('Institution'));
+        $this->out->element('div', 'field', $field['school']);
+        $this->out->element('div', 'label', _m('Degree'));
+        $this->out->element('div', 'field', $field['degree']);
+        $this->out->element('div', 'label', _m('Description'));
+        $this->out->element('div', 'field', $field['description']);
+        $this->out->element('div', 'label', _m('Start'));
+        $this->out->element(
+            'div',
+            array('class' => 'field date'),
+            date('j M Y', strtotime($field['start'])
+            )
+        );
+        $this->out->element('div', 'label', _m('End'));
+        $this->out->element(
+            'div',
+            array('class' => 'field date'),
+            date('j M Y', strtotime($field['end'])
+            )
+        );
+        $this->out->elementEnd('div');
+    }
+
+    protected function showEditableEducation($name, $field)
+    {
+        $index = isset($field['index']) ? $field['index'] : 0;
+        $id    = "extprofile-$name-$index";
+        $this->out->elementStart(
+            'div', array(
+                'id' => $id . '-edit',
+                'class' => 'education-item'
+            )
+        );
+        $this->out->element('div', 'label', _m('Institution'));
+        $this->out->input(
+            $id,
+            null,
+            isset($field['school']) ? $field['school'] : null
+        );
+
+        $this->out->element('div', 'label', _m('Degree'));
+        $this->out->input(
+            $id . '-degree',
+            null,
+            isset($field['degree']) ? $field['degree'] : null
+        );
+
+        $this->out->element('div', 'label', _m('Description'));
+        $this->out->element('div', 'field', $field['description']);
+
+        $this->out->textarea(
+            $id . '-description',
+            null,
+            isset($field['description']) ? $field['description'] : null
+        );
+
+        $this->out->element('div', 'label', _m('Start'));
+        $this->out->input(
+            $id . '-start',
+            null,
+            isset($field['start']) ? date('j M Y', strtotime($field['start'])) : null
+        );
+
+        $this->out->element('div', 'label', _m('End'));
+        $this->out->input(
+            $id . '-end',
+            null,
+            isset($field['end']) ? date('j M Y', strtotime($field['end'])) : null
+        );
+
+        $this->showMultiControls();
+        $this->out->elementEnd('div');
+    }
+
+    function showMultiControls()
+    {
+        $this->out->element(
+            'a',
+            array(
+                'class' => 'remove_row',
+                'href' => 'javascript://',
+                'style' => 'display: none;'
+            ),
+            '-'
+        );
+
+        $this->out->element(
+            'a',
+            array(
+                'class' => 'add_row',
+                'href' => 'javascript://',
+                'style' => 'display: none;'
+            ),
+            'Add another item'
+        );
+    }
+
+    /**
+     * Outputs the value of a field
+     *
+     * @param string $name  name of the field
+     * @param array  $field set of key/value pairs for the field
+     */
     protected function showFieldValue($name, $field)
     {
-        $this->out->text($name);
+        $type = strval(@$field['type']);
+
+        switch($type)
+        {
+        case '':
+        case 'text':
+        case 'textarea':
+            $this->out->text($this->ext->getTextValue($name));
+            break;
+        case 'date':
+            $value = $this->ext->getDateValue($name);
+            if (!empty($value)) {
+                $this->out->element(
+                    'div',
+                    array('class' => 'field date'),
+                    date('j M Y', strtotime($value))
+                );
+            }
+            break;
+        case 'person':
+            $this->out->text($this->ext->getTextValue($name));
+            break;
+        case 'tags':
+            $this->out->text($this->ext->getTags());
+            break;
+        case 'phone':
+            $this->showPhone($name, $field);
+            break;
+        case 'website':
+            $this->showWebsite($name, $field);
+            break;
+        case 'im':
+            $this->showIm($name, $field);
+            break;
+        case 'experience':
+            $this->showExperience($name, $field);
+            break;
+        case 'education':
+            $this->showEducation($name, $field);
+            break;
+        default:
+            $this->out->text("TYPE: $type");
+        }
     }
 
+    /**
+     * Show an editable version of the field
+     *
+     * @param string $name  name fo the field
+     * @param array  $field array of key/value pairs for the field
+     */
     protected function showEditableField($name, $field)
     {
         $out = $this->out;
-        //$out = new HTMLOutputter();
-        // @fixme
+
         $type = strval(@$field['type']);
         $id = "extprofile-" . $name;
+
         $value = 'placeholder';
 
         switch ($type) {
-            case '':
-            case 'text':
-                $out->input($id, null, $value);
-                break;
-            case 'textarea':
-                $out->textarea($id, null, $value);
-                break;
-            default:
-                $out->input($id, null, "TYPE: $type");
+        case '':
+        case 'text':
+            $out->input($id, null, $this->ext->getTextValue($name));
+            break;
+        case 'date':
+            $out->input(
+                $id,
+                null,
+                date('j M Y', strtotime($this->ext->getDateValue($name)))
+            );
+            break;
+        case 'person':
+            $out->input($id, null, $this->ext->getTextValue($name));
+            break;
+        case 'textarea':
+            $out->textarea($id, null,  $this->ext->getTextValue($name));
+            break;
+        case 'tags':
+            $out->input($id, null, $this->ext->getTags());
+            break;
+        case 'phone':
+            $this->showEditablePhone($name, $field);
+            break;
+        case 'im':
+            $this->showEditableIm($name, $field);
+            break;
+        case 'website':
+            $this->showEditableWebsite($name, $field);
+            break;
+        case 'experience':
+            $this->showEditableExperience($name, $field);
+            break;
+        case 'education':
+            $this->showEditableEducation($name, $field);
+            break;
+        default:
+            $out->input($id, null, "TYPE: $type");
         }
     }
+
+    /**
+     * Action elements
+     *
+     * @return void
+     */
+
+    function formActions()
+    {
+        $this->out->submit(
+            'save',
+            _m('BUTTON','Save'),
+            'submit form_action-secondary',
+            'save',
+            _('Save details')
+       );
+    }
+
+    /**
+     * ID of the form
+     *
+     * @return string ID of the form
+     */
+
+    function id()
+    {
+        return 'profile-details-' . $this->profile->id;
+    }
+
+    /**
+     * class of the form
+     *
+     * @return string of the form class
+     */
+
+    function formClass()
+    {
+        return 'form_profile_details form_settings';
+    }
+
+    /**
+     * Action of the form
+     *
+     * @return string URL of the action
+     */
+
+    function action()
+    {
+        return common_local_url('profiledetailsettings');
+    }
 }
diff --git a/plugins/ExtendedProfile/js/profiledetail.js b/plugins/ExtendedProfile/js/profiledetail.js
new file mode 100644 (file)
index 0000000..99a3f78
--- /dev/null
@@ -0,0 +1,144 @@
+var SN_EXTENDED = SN_EXTENDED || {};
+
+SN_EXTENDED.reorder = function(cls) {
+
+    var divs = $('div[class=' + cls + ']');
+
+    $(divs).each(function(i, div) {
+        $(div).find('a.add_row').hide();
+        $(div).find('a.remove_row').show();
+        SN_EXTENDED.replaceIndex(SN_EXTENDED.rowIndex(div), i);
+    });
+
+    var lastDiv = $(divs).last().closest('tr');
+    lastDiv.addClass('supersizeme');
+
+    $(divs).last().find('a.add_row').show();
+
+    if (divs.length == 1) {
+        $(divs).find('a.remove_row').fadeOut("slow");
+    }
+};
+
+SN_EXTENDED.rowIndex = function(div) {
+    var idstr = $(div).attr('id');
+    var id = idstr.match(/\d+/);
+    return id;
+};
+
+SN_EXTENDED.rowCount = function(cls) {
+    var divs = $.find('div[class=' + cls + ']');
+    return divs.length;
+};
+
+SN_EXTENDED.replaceIndex = function(elem, oldIndex, newIndex) {
+    $(elem).find('*').each(function() {
+        $.each(this.attributes, function(i, attrib) {
+            var regexp = /extprofile-.*-\d.*/;
+            var value = attrib.value;
+            var match = value.match(regexp);
+            if (match !== null) {
+                attrib.value = value.replace("-" + oldIndex, "-" + newIndex);
+            }
+        });
+    });
+}
+
+SN_EXTENDED.resetRow = function(elem) {
+    $(elem).find('input, textarea').attr('value', '');
+    $(elem).find('input').removeAttr('disabled');
+    $(elem).find("select option[value='office']").attr("selected", true);
+    $(elem).find("input:checkbox").attr('checked', false);
+    $(elem).find("input[name$=-start], input[name$=-end]").each(function() {
+        $(this).removeClass('hasDatepicker');
+        $(this).datepicker({ dateFormat: 'd M yy' });
+    });
+};
+
+SN_EXTENDED.addRow = function() {
+    var div = $(this).closest('div');
+    var id = div.attr('id');
+    var cls = div.attr('class');
+    var index = id.match(/\d+/);
+    var newIndex = parseInt(index) + 1;
+    var newtr = $(div).closest('tr').removeClass('supersizeme').clone();
+    SN_EXTENDED.replaceIndex(newtr, index, newIndex);
+    SN_EXTENDED.resetRow(newtr);
+    $(div).closest('tr').after(newtr);
+    SN_EXTENDED.reorder(cls);
+};
+
+SN_EXTENDED.removeRow = function() {
+
+    var div = $(this).closest('div');
+    var id = $(div).attr('id');
+    var cls = $(div).attr('class');
+    var that = this;
+
+    $("#confirm-dialog").dialog({
+        buttons : {
+            "Confirm" : function() {
+                $(this).dialog("close");
+                var target = $(that).closest('tr');
+                target.fadeOut("slow", function() {
+                    $(target).remove();
+                    SN_EXTENDED.reorder(cls);
+                });
+            },
+            "Cancel" : function() {
+                $(this).dialog("close");
+            }
+        }
+    });
+
+    var cnt = SN_EXTENDED.rowCount(cls);
+
+    if (cnt > 1) {
+        $("#confirm-dialog").dialog("open");
+    }
+};
+
+$(document).ready(function() {
+
+    $("#confirm-dialog").dialog({
+        autoOpen: false,
+        modal: true
+    });
+
+    $("input#extprofile-manager").autocomplete({
+        source: 'finduser',
+        minLength: 2 });
+
+    $("input[name$=-start], input[name$=-end], #extprofile-birthday").datepicker({ dateFormat: 'd M yy' });
+
+    var multifields = ["phone-item", "experience-item", "education-item", "im-item", 'website-item'];
+
+    for (f in multifields) {
+        SN_EXTENDED.reorder(multifields[f]);
+    }
+
+    $("input#extprofile-manager").autocomplete({
+        source: 'finduser',
+        minLength: 2 });
+
+    $('.add_row').live('click', SN_EXTENDED.addRow);
+    $('.remove_row').live('click', SN_EXTENDED.removeRow);
+
+    $('input:checkbox[name$=current]').each(function() {
+        var input = $(this).parent().siblings('input[id$=-end]');
+        if ($(this).is(':checked')) {
+            $(input).attr('disabled', 'true');
+        }
+    });
+
+    $('input:checkbox[name$=current]').live('click', function()  {
+        var input = $(this).parent().siblings('input[id$=-end]');
+        if ($(this).is(':checked')) {
+            $(input).val('');
+            $(input).attr('disabled', 'true');
+        } else {
+            $(input).removeAttr('disabled');
+        }
+    });
+
+});
diff --git a/plugins/ExtendedProfile/profiledetail.css b/plugins/ExtendedProfile/profiledetail.css
deleted file mode 100644 (file)
index 836b647..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-/* Note the #content is only needed to override weird crap in default styles */
-
-#content table.extended-profile {
-    width: 100%;
-    border-collapse: separate;
-    border-spacing: 8px;
-}
-#content table.extended-profile th {
-    color: #777;
-    background-color: #eee;
-    width: 150px;
-
-    padding-top: 0; /* override bizarre theme defaults */
-
-    text-align: right;
-    padding-right: 8px;
-}
-#content table.extended-profile td {
-    padding: 0; /* override bizarre theme defaults */
-
-    padding-left: 8px;
-}
\ No newline at end of file
index a4bb12956e5e002016c661809c9febd6a56740a7..a777a28e031cacbab9a47b2335fc97ad12c689da 100644 (file)
@@ -21,8 +21,9 @@ if (!defined('STATUSNET')) {
     exit(1);
 }
 
-class ProfileDetailAction extends ProfileAction
+class ProfileDetailAction extends ShowstreamAction
 {
+
     function isReadOnly($args)
     {
         return true;
@@ -33,28 +34,18 @@ class ProfileDetailAction extends ProfileAction
         return $this->profile->getFancyName();
     }
 
-    function showLocalNav()
-    {
-        $nav = new PersonalGroupNav($this);
-        $nav->show();
-    }
-
     function showStylesheets() {
         parent::showStylesheets();
-        $this->cssLink('plugins/ExtendedProfile/profiledetail.css');
+        $this->cssLink('plugins/ExtendedProfile/css/profiledetail.css');
         return true;
     }
 
-    function handle($args)
-    {
-        $this->showPage();
-    }
-
     function showContent()
     {
         $cur = common_current_user();
         if ($cur && $cur->id == $this->profile->id) { // your own page
             $this->elementStart('div', 'entity_actions');
+            $this->elementStart('ul');
             $this->elementStart('li', 'entity_edit');
             $this->element('a', array('href' => common_local_url('profiledetailsettings'),
                                       // TRANS: Link title for link on user profile.
@@ -62,6 +53,7 @@ class ProfileDetailAction extends ProfileAction
                            // TRANS: Link text for link on user profile.
                            _m('Edit'));
             $this->elementEnd('li');
+            $this->elementEnd('ul');
             $this->elementEnd('div');
         }
 
index 77d755c0b0f6be9813472c111d00d5e71f4f8459..7b03f247edc24c4f2b74973c0750edced615e701 100644 (file)
@@ -21,7 +21,7 @@ if (!defined('STATUSNET')) {
     exit(1);
 }
 
-class ProfileDetailSettingsAction extends AccountSettingsAction
+class ProfileDetailSettingsAction extends ProfileSettingsAction
 {
 
     function title()
@@ -43,13 +43,38 @@ class ProfileDetailSettingsAction extends AccountSettingsAction
 
     function showStylesheets() {
         parent::showStylesheets();
-        $this->cssLink('plugins/ExtendedProfile/profiledetail.css');
+        $this->cssLink('plugins/ExtendedProfile/css/profiledetail.css');
+        $this->cssLink('http://ajax.googleapis.com/ajax/libs/jqueryui/1.8/themes/base/jquery-ui.css');
         return true;
     }
 
-    function handle($args)
+    function  showScripts() {
+        parent::showScripts();
+        $this->script('plugins/ExtendedProfile/js/profiledetail.js');
+        $this->script('http://ajax.googleapis.com/ajax/libs/jqueryui/1.8/jquery-ui.min.js');
+        return true;
+    }
+
+    function handlePost()
     {
-        $this->showPage();
+        // CSRF protection
+        $token = $this->trimmed('token');
+        if (!$token || $token != common_session_token()) {
+            $this->showForm(
+                _m(
+                    'There was a problem with your session token. '
+                    .   'Try again, please.'
+                  )
+            );
+            return;
+        }
+
+        if ($this->arg('save')) {
+            $this->saveDetails();
+        } else {
+            // TRANS: Message given submitting a form with an unknown action
+            $this->showForm(_m('Unexpected form submission.'));
+        }
     }
 
     function showContent()
@@ -57,7 +82,554 @@ class ProfileDetailSettingsAction extends AccountSettingsAction
         $cur = common_current_user();
         $profile = $cur->getProfile();
 
-        $widget = new ExtendedProfileWidget($this, $profile, ExtendedProfileWidget::EDITABLE);
+        $widget = new ExtendedProfileWidget(
+            $this,
+            $profile,
+            ExtendedProfileWidget::EDITABLE
+        );
         $widget->show();
     }
+
+    function saveDetails()
+    {
+        common_debug(var_export($_POST, true));
+
+        $user = common_current_user();
+
+        try {
+            $this->saveStandardProfileDetails($user);
+
+            $profile = $user->getProfile();
+
+            $simpleFieldNames = array('title', 'spouse', 'kids', 'manager');
+            $dateFieldNames   = array('birthday');
+
+            foreach ($simpleFieldNames as $name) {
+                $value = $this->trimmed('extprofile-' . $name);
+                if (!empty($value)) {
+                    $this->saveField($user, $name, $value);
+                }
+            }
+
+            foreach ($dateFieldNames as $name) {
+                $value = $this->trimmed('extprofile-' . $name);
+                $dateVal = $this->parseDate($name, $value);
+                $this->saveField(
+                    $user,
+                    $name,
+                    null,
+                    null,
+                    null,
+                    $dateVal
+                );
+            }
+
+            $this->savePhoneNumbers($user);
+            $this->saveIms($user);
+            $this->saveWebsites($user);
+            $this->saveExperiences($user);
+            $this->saveEducations($user);
+
+        } catch (Exception $e) {
+            $this->showForm($e->getMessage(), false);
+            return;
+        }
+
+        $this->showForm(_('Details saved.'), true);
+
+    }
+
+    function parseDate($fieldname, $datestr, $required = false)
+    {
+        if (empty($datestr) && $required) {
+            $msg = sprintf(
+                _m('You must supply a date for "%s".'),
+                $fieldname
+            );
+            throw new Exception($msg);
+        } else {
+            $ts = strtotime($datestr);
+            if ($ts === false) {
+                throw new Exception(
+                    sprintf(
+                        _m('Invalid date entered for "%s": %s'),
+                        $fieldname,
+                        $ts
+                    )
+                );
+            }
+            return common_sql_date($ts);
+        }
+        return null;
+    }
+
+    function savePhoneNumbers($user) {
+        $phones = $this->findPhoneNumbers();
+        $this->removeAll($user, 'phone');
+        $i = 0;
+        foreach($phones as $phone) {
+            if (!empty($phone['value'])) {
+                ++$i;
+                $this->saveField(
+                    $user,
+                    'phone',
+                    $phone['value'],
+                    $phone['rel'],
+                    $i
+                );
+            }
+        }
+    }
+
+    function findPhoneNumbers() {
+
+        // Form vals look like this:
+        // 'extprofile-phone-1' => '11332',
+        // 'extprofile-phone-1-rel' => 'mobile',
+
+        $phones     = $this->sliceParams('phone', 2);
+        $phoneArray = array();
+
+        foreach ($phones as $phone) {
+            list($number, $rel) = array_values($phone);
+            $phoneArray[] = array(
+                'value' => $number,
+                'rel'   => $rel
+            );
+        }
+
+        return $phoneArray;
+    }
+
+    function findIms() {
+
+        //  Form vals look like this:
+        // 'extprofile-im-0' => 'jed',
+        // 'extprofile-im-0-rel' => 'yahoo',
+
+        $ims     = $this->sliceParams('im', 2);
+        $imArray = array();
+
+        foreach ($ims as $im) {
+            list($id, $rel) = array_values($im);
+            $imArray[] = array(
+                'value' => $id,
+                'rel'   => $rel
+            );
+        }
+
+        return $imArray;
+    }
+
+    function saveIms($user) {
+        $ims = $this->findIms();
+        $this->removeAll($user, 'im');
+        $i = 0;
+        foreach($ims as $im) {
+            if (!empty($im['value'])) {
+                ++$i;
+                $this->saveField(
+                    $user,
+                    'im',
+                    $im['value'],
+                    $im['rel'],
+                    $i
+                );
+            }
+        }
+    }
+
+    function findWebsites() {
+
+        //  Form vals look like this:
+
+        $sites = $this->sliceParams('website', 2);
+        $wsArray = array();
+
+        foreach ($sites as $site) {
+            list($id, $rel) = array_values($site);
+            $wsArray[] = array(
+                'value' => $id,
+                'rel'   => $rel
+            );
+        }
+
+        return $wsArray;
+    }
+
+    function saveWebsites($user) {
+        $sites = $this->findWebsites();
+        $this->removeAll($user, 'website');
+        $i = 0;
+        foreach($sites as $site) {
+
+            if (!Validate::uri(
+                $site['value'],
+                array('allowed_schemes' => array('http', 'https')))
+            ) {
+                throw new Exception(sprintf(_m('Invalid URL: %s'), $site['value']));
+            }
+
+            if (!empty($site['value'])) {
+                ++$i;
+                $this->saveField(
+                    $user,
+                    'website',
+                    $site['value'],
+                    $site['rel'],
+                    $i
+                );
+            }
+        }
+    }
+
+    function findExperiences() {
+
+        // Form vals look like this:
+        // 'extprofile-experience-0'         => 'Bozotronix',
+        // 'extprofile-experience-0-current' => 'true'
+        // 'extprofile-experience-0-start'   => '1/5/10',
+        // 'extprofile-experience-0-end'     => '2/3/11',
+
+        $experiences = $this->sliceParams('experience', 4);
+        $expArray = array();
+
+        foreach ($experiences as $exp) {
+            if (sizeof($experiences) == 4) {
+                list($company, $current, $end, $start) = array_values($exp);
+            } else {
+                $end = null;
+                list($company, $current, $start) = array_values($exp);
+            }
+            if (!empty($company)) {
+                $expArray[] = array(
+                    'company' => $company,
+                    'start'   => $this->parseDate('Start', $start, true),
+                    'end'     => ($current == 'false') ? $this->parseDate('End', $end, true) : null,
+                    'current' => ($current == 'false') ? false : true
+                );
+            }
+        }
+
+        return $expArray;
+    }
+
+    function saveExperiences($user) {
+        common_debug('save experiences');
+        $experiences = $this->findExperiences();
+
+        $this->removeAll($user, 'company');
+        $this->removeAll($user, 'start');
+        $this->removeAll($user, 'end'); // also stores 'current'
+
+        $i = 0;
+        foreach($experiences as $experience) {
+            if (!empty($experience['company'])) {
+                ++$i;
+                $this->saveField(
+                    $user,
+                    'company',
+                    $experience['company'],
+                    null,
+                    $i
+                );
+
+                $this->saveField(
+                    $user,
+                    'start',
+                    null,
+                    null,
+                    $i,
+                    $experience['start']
+                );
+
+                // Save "current" employer indicator in rel
+                if ($experience['current']) {
+                    $this->saveField(
+                        $user,
+                        'end',
+                        null,
+                        'current', // rel
+                        $i
+                    );
+                } else {
+                    $this->saveField(
+                        $user,
+                        'end',
+                        null,
+                        null,
+                        $i,
+                        $experience['end']
+                    );
+                }
+
+            }
+        }
+    }
+
+    function findEducations() {
+
+        // Form vals look like this:
+        // 'extprofile-education-0-school' => 'Pigdog',
+        // 'extprofile-education-0-degree' => 'BA',
+        // 'extprofile-education-0-description' => 'Blar',
+        // 'extprofile-education-0-start' => '05/22/99',
+        // 'extprofile-education-0-end' => '05/22/05',
+
+        $edus = $this->sliceParams('education', 5);
+        $eduArray = array();
+
+        foreach ($edus as $edu) {
+            list($school, $degree, $description, $end, $start) = array_values($edu);
+            if (!empty($school)) {
+                $eduArray[] = array(
+                    'school'      => $school,
+                    'degree'      => $degree,
+                    'description' => $description,
+                    'start'       => $this->parseDate('Start', $start, true),
+                    'end'         => $this->parseDate('End', $end, true)
+                );
+            }
+        }
+
+        return $eduArray;
+    }
+
+
+    function saveEducations($user) {
+         common_debug('save education');
+         $edus = $this->findEducations();
+         common_debug(var_export($edus, true));
+
+         $this->removeAll($user, 'school');
+         $this->removeAll($user, 'degree');
+         $this->removeAll($user, 'degree_descr');
+         $this->removeAll($user, 'school_start');
+         $this->removeAll($user, 'school_end');
+
+         $i = 0;
+         foreach($edus as $edu) {
+             if (!empty($edu['school'])) {
+                 ++$i;
+                 $this->saveField(
+                     $user,
+                     'school',
+                     $edu['school'],
+                     null,
+                     $i
+                 );
+                 $this->saveField(
+                     $user,
+                     'degree',
+                     $edu['degree'],
+                     null,
+                     $i
+                 );
+                 $this->saveField(
+                     $user,
+                     'degree_descr',
+                     $edu['description'],
+                     null,
+                     $i
+                 );
+                 $this->saveField(
+                     $user,
+                     'school_start',
+                     null,
+                     null,
+                     $i,
+                     $edu['start']
+                 );
+
+                 $this->saveField(
+                     $user,
+                     'school_end',
+                     null,
+                     null,
+                     $i,
+                     $edu['end']
+                 );
+            }
+         }
+     }
+
+    function arraySplit($array, $pieces)
+    {
+        if ($pieces < 2) {
+            return array($array);
+        }
+
+        $newCount = ceil(count($array) / $pieces);
+        $a = array_slice($array, 0, $newCount);
+        $b = $this->arraySplit(array_slice($array, $newCount), $pieces - 1);
+
+        return array_merge(array($a), $b);
+    }
+
+    function findMultiParams($type) {
+        $formVals = array();
+        $target   = $type;
+        foreach ($_POST as $key => $val) {
+            if (strrpos('extprofile-' . $key, $target) !== false) {
+                $formVals[$key] = $val;
+            }
+        }
+        return $formVals;
+    }
+
+    function sliceParams($key, $size) {
+        $slice = array();
+        $params = $this->findMultiParams($key);
+        ksort($params);
+        $slice = $this->arraySplit($params, sizeof($params) / $size);
+        return $slice;
+    }
+
+    /**
+     * Save an extended profile field as a Profile_detail
+     *
+     * @param User   $user    the current user
+     * @param string $name    field name
+     * @param string $value   field value
+     * @param string $rel     field rel (type)
+     * @param int    $index   index (fields can have multiple values)
+     * @param date   $date    related date
+     */
+    function saveField($user, $name, $value, $rel = null, $index = null, $date = null)
+    {
+        $profile = $user->getProfile();
+        $detail  = new Profile_detail();
+
+        $detail->profile_id  = $profile->id;
+        $detail->field_name  = $name;
+        $detail->value_index = $index;
+
+        $result = $detail->find(true);
+
+        if (empty($result)) {
+            $detial->value_index = $index;
+            $detail->rel         = $rel;
+            $detail->field_value = $value;
+            $detail->date        = $date;
+            $detail->created     = common_sql_now();
+            $result = $detail->insert();
+            if (empty($result)) {
+                common_log_db_error($detail, 'INSERT', __FILE__);
+                $this->serverError(_m('Could not save profile details.'));
+            }
+        } else {
+            $orig = clone($detail);
+
+            $detail->field_value = $value;
+            $detail->rel         = $rel;
+            $detail->date        = $date;
+
+            $result = $detail->update($orig);
+            if (empty($result)) {
+                common_log_db_error($detail, 'UPDATE', __FILE__);
+                $this->serverError(_m('Could not save profile details.'));
+            }
+        }
+
+        $detail->free();
+    }
+
+    function removeAll($user, $name)
+    {
+        $profile = $user->getProfile();
+        $detail  = new Profile_detail();
+        $detail->profile_id  = $profile->id;
+        $detail->field_name  = $name;
+        $detail->delete();
+        $detail->free();
+    }
+
+    /**
+     * Save fields that should be stored in the main profile object
+     *
+     * XXX: There's a lot of dupe code here from ProfileSettingsAction.
+     *      Do not want.
+     *
+     * @param User $user the current user
+     */
+    function saveStandardProfileDetails($user)
+    {
+        $fullname  = $this->trimmed('extprofile-fullname');
+        $location  = $this->trimmed('extprofile-location');
+        $tagstring = $this->trimmed('extprofile-tags');
+        $bio       = $this->trimmed('extprofile-bio');
+
+        if ($tagstring) {
+            $tags = array_map(
+                'common_canonical_tag',
+                preg_split('/[\s,]+/', $tagstring)
+            );
+        } else {
+            $tags = array();
+        }
+
+        foreach ($tags as $tag) {
+            if (!common_valid_profile_tag($tag)) {
+                // TRANS: Validation error in form for profile settings.
+                // TRANS: %s is an invalid tag.
+                throw new Exception(sprintf(_m('Invalid tag: "%s".'), $tag));
+            }
+        }
+
+        $profile = $user->getProfile();
+
+        $oldTags = $user->getSelfTags();
+        $newTags = array_diff($tags, $oldTags);
+
+        if ($fullname    != $profile->fullname
+            || $location != $profile->location
+            || !empty($newTags)
+            || $bio      != $profile->bio) {
+
+            $orig = clone($profile);
+
+            $profile->nickname = $user->nickname;
+            $profile->fullname = $fullname;
+            $profile->bio      = $bio;
+            $profile->location = $location;
+
+            $loc = Location::fromName($location);
+
+            if (empty($loc)) {
+                $profile->lat         = null;
+                $profile->lon         = null;
+                $profile->location_id = null;
+                $profile->location_ns = null;
+            } else {
+                $profile->lat         = $loc->lat;
+                $profile->lon         = $loc->lon;
+                $profile->location_id = $loc->location_id;
+                $profile->location_ns = $loc->location_ns;
+            }
+
+            $profile->profileurl = common_profile_url($user->nickname);
+
+            $result = $profile->update($orig);
+
+            if ($result === false) {
+                common_log_db_error($profile, 'UPDATE', __FILE__);
+                // TRANS: Server error thrown when user profile settings could not be saved.
+                $this->serverError(_('Could not save profile.'));
+                return;
+            }
+
+            // Set the user tags
+            $result = $user->setSelfTags($tags);
+
+            if (!$result) {
+                // TRANS: Server error thrown when user profile settings tags could not be saved.
+                $this->serverError(_('Could not save tags.'));
+                return;
+            }
+
+            Event::handle('EndProfileSaveForm', array($this));
+            common_broadcast_profile($profile);
+        }
+    }
+
 }
index 9b36d87f3795b8c27084b10e2020274f40059a5b..0c52427eb6a70ab069a15e6c91c4e25b2abc0efc 100644 (file)
@@ -1,9 +1,12 @@
+As of StatusNet 1.0.x, actual formatting of the notices is done server-side,
+loaded by AJAX after the real-time notification comes in. This has the drawback
+that we may make extra HTTP requests and delay incoming notices a little, but
+means that formatting and internationalization is consistent.
+
 == TODO ==
-* i18n
 * Update mark behaviour (on notice send)
 * Pause, Send a notice ~ should not update counter
 * Pause ~ retain up to 50-100 most recent notices
-* Add geo data
 * Make it work for Conversation page (perhaps a little tricky)
 * IE is updating the counter in document title all the time (Not sure if this
   is still an issue)
index 246b1f9735d9ef8596115c17cf95708404239649..108a6c3b60ff60b8e284f98f4d037ec9eeaa8701 100644 (file)
@@ -45,9 +45,7 @@ if (!defined('STATUSNET') && !defined('LACONICA')) {
  */
 class RealtimePlugin extends Plugin
 {
-    protected $replyurl = null;
-    protected $favorurl = null;
-    protected $deleteurl = null;
+    protected $showurl = null;
 
     /**
      * When it's time to initialize the plugin, calculate and
@@ -56,11 +54,8 @@ class RealtimePlugin extends Plugin
 
     function onInitializePlugin()
     {
-        $this->replyurl = common_local_url('newnotice');
-        $this->favorurl = common_local_url('favor');
-        $this->repeaturl = common_local_url('repeat');
         // FIXME: need to find a better way to pass this pattern in
-        $this->deleteurl = common_local_url('deletenotice',
+        $this->showurl = common_local_url('shownotice',
                                             array('notice' => '0000000000'));
         return true;
     }
@@ -323,7 +318,12 @@ class RealtimePlugin extends Plugin
 
     function _getScripts()
     {
-        return array(Plugin::staticPath('Realtime', 'realtimeupdate.min.js'));
+        if (common_config('site', 'minify')) {
+            $js = 'realtimeupdate.min.js';
+        } else {
+            $js = 'realtimeupdate.js';
+        }
+        return array(Plugin::staticPath('Realtime', $js));
     }
 
     /**
@@ -354,7 +354,7 @@ class RealtimePlugin extends Plugin
 
     function _updateInitialize($timeline, $user_id)
     {
-        return "RealtimeUpdate.init($user_id, \"$this->replyurl\", \"$this->favorurl\", \"$this->repeaturl\", \"$this->deleteurl\"); ";
+        return "RealtimeUpdate.init($user_id, \"$this->showurl\"); ";
     }
 
     function _connect()
index e615895cab9e3989ec9667d71be50041403d39b6..59e3fe72d770f19249bd26a99b8a94900eade18a 100644 (file)
  */
 RealtimeUpdate = {
      _userid: 0,
-     _replyurl: '',
-     _favorurl: '',
-     _repeaturl: '',
-     _deleteurl: '',
+     _showurl: '',
      _updatecounter: 0,
      _maxnotices: 50,
      _windowhasfocus: true,
@@ -66,21 +63,15 @@ RealtimeUpdate = {
       * feed data into the RealtimeUpdate object!
       *
       * @param {int} userid: local profile ID of the currently logged-in user
-      * @param {String} replyurl: URL for newnotice action, used when generating reply buttons
-      * @param {String} favorurl: URL for favor action, used when generating fave buttons
-      * @param {String} repeaturl: URL for repeat action, used when generating repeat buttons
-      * @param {String} deleteurl: URL template for deletenotice action, used when generating delete buttons.
+      * @param {String} showurl: URL for shownotice action, used when fetching formatting notices.
       *                            This URL contains a stub value of 0000000000 which will be replaced with the notice ID.
       *
       * @access public
       */
-     init: function(userid, replyurl, favorurl, repeaturl, deleteurl)
+     init: function(userid, showurl)
      {
         RealtimeUpdate._userid = userid;
-        RealtimeUpdate._replyurl = replyurl;
-        RealtimeUpdate._favorurl = favorurl;
-        RealtimeUpdate._repeaturl = repeaturl;
-        RealtimeUpdate._deleteurl = deleteurl;
+        RealtimeUpdate._showurl = showurl;
 
         RealtimeUpdate._documenttitle = document.title;
 
@@ -163,50 +154,51 @@ RealtimeUpdate = {
             return;
         }
 
-        var noticeItem = RealtimeUpdate.makeNoticeItem(data);
-        var noticeItemID = $(noticeItem).attr('id');
-
-        var list = $("#notices_primary .notices:first")
-        var prepend = true;
-
-        var threaded = list.hasClass('threaded-notices');
-        if (threaded && data.in_reply_to_status_id) {
-            // aho!
-            var parent = $('#notice-' + data.in_reply_to_status_id);
-            if (parent.length == 0) {
-                // @todo fetch the original, insert it, and finish the rest
-            } else {
-                // Check the parent notice to make sure it's not a reply itself.
-                // If so, use it's parent as the parent.
-                var parentList = parent.closest('.notices');
-                if (parentList.hasClass('threaded-replies')) {
-                    parent = parentList.closest('.notice');
-                }
-                list = parent.find('.threaded-replies');
-                if (list.length == 0) {
-                    list = $('<ul class="notices threaded-replies xoxo"></ul>');
-                    parent.append(list);
+        RealtimeUpdate.makeNoticeItem(data, function(noticeItem) {
+            var noticeItemID = $(noticeItem).attr('id');
+
+            var list = $("#notices_primary .notices:first")
+            var prepend = true;
+
+            var threaded = list.hasClass('threaded-notices');
+            if (threaded && data.in_reply_to_status_id) {
+                // aho!
+                var parent = $('#notice-' + data.in_reply_to_status_id);
+                if (parent.length == 0) {
+                    // @todo fetch the original, insert it, and finish the rest
+                } else {
+                    // Check the parent notice to make sure it's not a reply itself.
+                    // If so, use it's parent as the parent.
+                    var parentList = parent.closest('.notices');
+                    if (parentList.hasClass('threaded-replies')) {
+                        parent = parentList.closest('.notice');
+                    }
+                    list = parent.find('.threaded-replies');
+                    if (list.length == 0) {
+                        list = $('<ul class="notices threaded-replies xoxo"></ul>');
+                        parent.append(list);
+                    }
+                    prepend = false;
                 }
-                prepend = false;
             }
-        }
 
-        var newNotice = $(noticeItem);
-        if (prepend) {
-            list.prepend(newNotice);
-        } else {
-            var placeholder = list.find('li.notice-reply-placeholder')
-            if (placeholder.length > 0) {
-                newNotice.insertBefore(placeholder)
+            var newNotice = $(noticeItem);
+            if (prepend) {
+                list.prepend(newNotice);
             } else {
-                newNotice.appendTo(list);
-                SN.U.NoticeInlineReplyPlaceholder(parent);
+                var placeholder = list.find('li.notice-reply-placeholder')
+                if (placeholder.length > 0) {
+                    newNotice.insertBefore(placeholder)
+                } else {
+                    newNotice.appendTo(list);
+                    SN.U.NoticeInlineReplyPlaceholder(parent);
+                }
             }
-        }
-        newNotice.css({display:"none"}).fadeIn(1000);
+            newNotice.css({display:"none"}).fadeIn(1000);
 
-        SN.U.NoticeReplyTo($('#'+noticeItemID));
-        SN.U.NoticeWithAttachment($('#'+noticeItemID));
+            SN.U.NoticeReplyTo($('#'+noticeItemID));
+            SN.U.NoticeWithAttachment($('#'+noticeItemID));
+        });
      },
 
      /**
@@ -263,86 +255,24 @@ RealtimeUpdate = {
      },
 
      /**
-      * Builds a notice HTML block from JSON API-style data.
+      * Builds a notice HTML block from JSON API-style data;
+      * loads data from server, so runs async.
       *
       * @param {Object} data: extended JSON API-formatted notice
-      * @return {String} HTML fragment
-      *
-      * @fixme this replicates core StatusNet code, making maintenance harder
-      * @fixme sloppy HTML building (raw concat without escaping)
-      * @fixme no i18n support
-      * @fixme local variables pollute global namespace
+      * @param {function} callback: function(DOMNode) to receive new code
       *
       * @access private
       */
-     makeNoticeItem: function(data)
+     makeNoticeItem: function(data, callback)
      {
-          if (data.hasOwnProperty('retweeted_status')) {
-               original = data['retweeted_status'];
-               repeat   = data;
-               data     = original;
-               unique   = repeat['id'];
-               responsible = repeat['user'];
-          } else {
-               original = null;
-               repeat = null;
-               unique = data['id'];
-               responsible = data['user'];
-          }
-
-          user = data['user'];
-          html = data['html'].replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'"').replace(/&amp;/g,'&');
-          source = data['source'].replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'"').replace(/&amp;/g,'&');
-
-          ni = "<li class=\"hentry notice\" id=\"notice-"+unique+"\">"+
-               "<div class=\"entry-title\">"+
-               "<span class=\"vcard author\">"+
-               "<a href=\""+user['profile_url']+"\" class=\"url\" title=\""+user['name']+"\">"+
-               "<img src=\""+user['profile_image_url']+"\" class=\"avatar photo\" width=\"48\" height=\"48\" alt=\""+user['screen_name']+"\"/>"+
-               "<span class=\"nickname fn\">"+user['screen_name']+"</span>"+
-               "</a>"+
-               "</span>"+
-               "<p class=\"entry-content\">"+html+"</p>"+
-               "</div>"+
-               "<div class=\"entry-content\">"+
-               "<a class=\"timestamp\" rel=\"bookmark\" href=\""+data['url']+"\" >"+
-               "<abbr class=\"published\" title=\""+data['created_at']+"\">a few seconds ago</abbr>"+
-               "</a> "+
-               "<span class=\"source\">"+
-               "from "+
-                "<span class=\"device\">"+source+"</span>"+ // may have a link
-               "</span>";
-          if (data['conversation_url']) {
-               ni = ni+" <a class=\"response\" href=\""+data['conversation_url']+"\">in context</a>";
-          }
-
-          if (repeat) {
-               ru = repeat['user'];
-               ni = ni + "<span class=\"repeat vcard\">Repeated by " +
-                    "<a href=\"" + ru['profile_url'] + "\" class=\"url\">" +
-                    "<span class=\"nickname\">"+ ru['screen_name'] + "</span></a></span>";
-          }
-
-          ni = ni+"</div>";
-
-          ni = ni + "<div class=\"notice-options\">";
-
-          if (RealtimeUpdate._userid != 0) {
-               var input = $("form#form_notice fieldset input#token");
-               var session_key = input.val();
-               ni = ni+RealtimeUpdate.makeFavoriteForm(data['id'], session_key);
-               ni = ni+RealtimeUpdate.makeReplyLink(data['id'], data['user']['screen_name']);
-               if (RealtimeUpdate._userid == responsible['id']) {
-                    ni = ni+RealtimeUpdate.makeDeleteLink(data['id']);
-               } else if (RealtimeUpdate._userid != user['id']) {
-                    ni = ni+RealtimeUpdate.makeRepeatForm(data['id'],  session_key);
-               }
-          }
-
-          ni = ni+"</div>";
-
-          ni = ni+"</li>";
-          return ni;
+         var url = RealtimeUpdate._showurl.replace('0000000000', data.id);
+         $.get(url, {ajax: 1}, function(data, textStatus, xhr) {
+             var notice = $('li.notice:first', data);
+             if (notice.length) {
+                 var node = document._importNode(notice[0], true);
+                 callback(node);
+             }
+         });
      },
 
      /**
index 931de982efdc4d4d7342390c54ae2fff556b25ac..7e77f9070999aa49709a638f37b354733e0ef121 100644 (file)
@@ -1 +1 @@
-RealtimeUpdate={_userid:0,_replyurl:"",_favorurl:"",_repeaturl:"",_deleteurl:"",_updatecounter:0,_maxnotices:50,_windowhasfocus:true,_documenttitle:"",_paused:false,_queuedNotices:[],init:function(c,b,d,e,a){RealtimeUpdate._userid=c;RealtimeUpdate._replyurl=b;RealtimeUpdate._favorurl=d;RealtimeUpdate._repeaturl=e;RealtimeUpdate._deleteurl=a;RealtimeUpdate._documenttitle=document.title;$(window).bind("focus",function(){RealtimeUpdate._windowhasfocus=true;RealtimeUpdate._updatecounter=0;RealtimeUpdate.removeWindowCounter()});$(window).bind("blur",function(){$("#notices_primary .notice").removeClass("mark-top");$("#notices_primary .notice:first").addClass("mark-top");RealtimeUpdate._windowhasfocus=false;return false})},receive:function(a){if(RealtimeUpdate.isNoticeVisible(a.id)){return}if(RealtimeUpdate._paused===false){RealtimeUpdate.purgeLastNoticeItem();RealtimeUpdate.insertNoticeItem(a)}else{RealtimeUpdate._queuedNotices.push(a);RealtimeUpdate.updateQueuedCounter()}RealtimeUpdate.updateWindowCounter()},insertNoticeItem:function(b){if(RealtimeUpdate.isNoticeVisible(b.id)){return}var a=RealtimeUpdate.makeNoticeItem(b);var c=$(a).attr("id");var d=$("#notices_primary .notices:first");var j=true;var e=d.hasClass("threaded-notices");if(e&&b.in_reply_to_status_id){var g=$("#notice-"+b.in_reply_to_status_id);if(g.length==0){}else{var h=g.closest(".notices");if(h.hasClass("threaded-replies")){g=h.closest(".notice")}d=g.find(".threaded-replies");if(d.length==0){d=$('<ul class="notices threaded-replies xoxo"></ul>');g.append(d)}j=false}}var i=$(a);if(j){d.prepend(i)}else{var f=d.find("li.notice-reply-placeholder");if(f.length>0){i.insertBefore(f)}else{i.appendTo(d);SN.U.NoticeInlineReplyPlaceholder(g)}}i.css({display:"none"}).fadeIn(1000);SN.U.NoticeReplyTo($("#"+c));SN.U.NoticeWithAttachment($("#"+c))},isNoticeVisible:function(a){return($("#notice-"+a).length>0)},purgeLastNoticeItem:function(){if($("#notices_primary .notice").length>RealtimeUpdate._maxnotices){$("#notices_primary .notice:last").remove()}},updateWindowCounter:function(){if(RealtimeUpdate._windowhasfocus===false){RealtimeUpdate._updatecounter+=1;document.title="("+RealtimeUpdate._updatecounter+") "+RealtimeUpdate._documenttitle}},removeWindowCounter:function(){document.title=RealtimeUpdate._documenttitle},makeNoticeItem:function(c){if(c.hasOwnProperty("retweeted_status")){original=c.retweeted_status;repeat=c;c=original;unique=repeat.id;responsible=repeat.user}else{original=null;repeat=null;unique=c.id;responsible=c.user}user=c.user;html=c.html.replace(/&lt;/g,"<").replace(/&gt;/g,">").replace(/&quot;/g,'"').replace(/&amp;/g,"&");source=c.source.replace(/&lt;/g,"<").replace(/&gt;/g,">").replace(/&quot;/g,'"').replace(/&amp;/g,"&");ni='<li class="hentry notice" id="notice-'+unique+'"><div class="entry-title"><span class="vcard author"><a href="'+user.profile_url+'" class="url" title="'+user.name+'"><img src="'+user.profile_image_url+'" class="avatar photo" width="48" height="48" alt="'+user.screen_name+'"/><span class="nickname fn">'+user.screen_name+'</span></a></span><p class="entry-content">'+html+'</p></div><div class="entry-content"><a class="timestamp" rel="bookmark" href="'+c.url+'" ><abbr class="published" title="'+c.created_at+'">a few seconds ago</abbr></a> <span class="source">from <span class="device">'+source+"</span></span>";if(c.conversation_url){ni=ni+' <a class="response" href="'+c.conversation_url+'">in context</a>'}if(repeat){ru=repeat.user;ni=ni+'<span class="repeat vcard">Repeated by <a href="'+ru.profile_url+'" class="url"><span class="nickname">'+ru.screen_name+"</span></a></span>"}ni=ni+"</div>";ni=ni+'<div class="notice-options">';if(RealtimeUpdate._userid!=0){var a=$("form#form_notice fieldset input#token");var b=a.val();ni=ni+RealtimeUpdate.makeFavoriteForm(c.id,b);ni=ni+RealtimeUpdate.makeReplyLink(c.id,c.user["screen_name"]);if(RealtimeUpdate._userid==responsible.id){ni=ni+RealtimeUpdate.makeDeleteLink(c.id)}else{if(RealtimeUpdate._userid!=user.id){ni=ni+RealtimeUpdate.makeRepeatForm(c.id,b)}}}ni=ni+"</div>";ni=ni+"</li>";return ni},makeFavoriteForm:function(c,b){var a;a='<form id="favor-'+c+'" class="form_favor" method="post" action="'+RealtimeUpdate._favorurl+'"><fieldset><legend>Favor this notice</legend><input name="token-'+c+'" type="hidden" id="token-'+c+'" value="'+b+'"/><input name="notice" type="hidden" id="notice-n'+c+'" value="'+c+'"/><input type="submit" id="favor-submit-'+c+'" name="favor-submit-'+c+'" class="submit" value="Favor" title="Favor this notice"/></fieldset></form>';return a},makeReplyLink:function(c,a){var b;b='<a class="notice_reply" href="'+RealtimeUpdate._replyurl+"?replyto="+a+'" title="Reply to this notice">Reply <span class="notice_id">'+c+"</span></a>";return b},makeRepeatForm:function(c,b){var a;a='<form id="repeat-'+c+'" class="form_repeat" method="post" action="'+RealtimeUpdate._repeaturl+'"><fieldset><legend>Repeat this notice?</legend><input name="token-'+c+'" type="hidden" id="token-'+c+'" value="'+b+'"/><input name="notice" type="hidden" id="notice-'+c+'" value="'+c+'"/><input type="submit" id="repeat-submit-'+c+'" name="repeat-submit-'+c+'" class="submit" value="Yes" title="Repeat this notice"/></fieldset></form>';return a},makeDeleteLink:function(c){var b,a;a=RealtimeUpdate._deleteurl.replace("0000000000",c);b='<a class="notice_delete" href="'+a+'" title="Delete this notice">Delete</a>';return b},initActions:function(a,b,c){$("#notices_primary").prepend('<ul id="realtime_actions"><li id="realtime_playpause"></li><li id="realtime_timeline"></li></ul>');RealtimeUpdate._pluginPath=c;RealtimeUpdate.initPlayPause();RealtimeUpdate.initAddPopup(a,b,RealtimeUpdate._pluginPath)},initPlayPause:function(){if(typeof(localStorage)=="undefined"){RealtimeUpdate.showPause()}else{if(localStorage.getItem("RealtimeUpdate_paused")==="true"){RealtimeUpdate.showPlay()}else{RealtimeUpdate.showPause()}}},showPause:function(){RealtimeUpdate.setPause(false);RealtimeUpdate.showQueuedNotices();RealtimeUpdate.addNoticesHover();$("#realtime_playpause").remove();$("#realtime_actions").prepend('<li id="realtime_playpause"><button id="realtime_pause" class="pause"></button></li>');$("#realtime_pause").text(SN.msg("realtime_pause")).attr("title",SN.msg("realtime_pause_tooltip")).bind("click",function(){RealtimeUpdate.removeNoticesHover();RealtimeUpdate.showPlay();return false})},showPlay:function(){RealtimeUpdate.setPause(true);$("#realtime_playpause").remove();$("#realtime_actions").prepend('<li id="realtime_playpause"><span id="queued_counter"></span> <button id="realtime_play" class="play"></button></li>');$("#realtime_play").text(SN.msg("realtime_play")).attr("title",SN.msg("realtime_play_tooltip")).bind("click",function(){RealtimeUpdate.showPause();return false})},setPause:function(a){RealtimeUpdate._paused=a;if(typeof(localStorage)!="undefined"){localStorage.setItem("RealtimeUpdate_paused",RealtimeUpdate._paused)}},showQueuedNotices:function(){$.each(RealtimeUpdate._queuedNotices,function(a,b){RealtimeUpdate.insertNoticeItem(b)});RealtimeUpdate._queuedNotices=[];RealtimeUpdate.removeQueuedCounter()},updateQueuedCounter:function(){$("#realtime_playpause #queued_counter").html("("+RealtimeUpdate._queuedNotices.length+")")},removeQueuedCounter:function(){$("#realtime_playpause #queued_counter").empty()},addNoticesHover:function(){$("#notices_primary .notices").hover(function(){if(RealtimeUpdate._paused===false){RealtimeUpdate.showPlay()}},function(){if(RealtimeUpdate._paused===true){RealtimeUpdate.showPause()}})},removeNoticesHover:function(){$("#notices_primary .notices").unbind()},initAddPopup:function(a,b,c){$("#realtime_timeline").append('<button id="realtime_popup"></button>');$("#realtime_popup").text(SN.msg("realtime_popup")).attr("title",SN.msg("realtime_popup_tooltip")).bind("click",function(){window.open(a,"","toolbar=no,resizable=yes,scrollbars=yes,status=no,menubar=no,personalbar=no,location=no,width=500,height=550");return false})},initPopupWindow:function(){$(".notices .entry-title a, .notices .entry-content a").bind("click",function(){window.open(this.href,"");return false});$("#showstream .entity_profile").css({width:"69%"})}};
\ No newline at end of file
+RealtimeUpdate={_userid:0,_showurl:"",_updatecounter:0,_maxnotices:50,_windowhasfocus:true,_documenttitle:"",_paused:false,_queuedNotices:[],init:function(a,b){RealtimeUpdate._userid=a;RealtimeUpdate._showurl=b;RealtimeUpdate._documenttitle=document.title;$(window).bind("focus",function(){RealtimeUpdate._windowhasfocus=true;RealtimeUpdate._updatecounter=0;RealtimeUpdate.removeWindowCounter()});$(window).bind("blur",function(){$("#notices_primary .notice").removeClass("mark-top");$("#notices_primary .notice:first").addClass("mark-top");RealtimeUpdate._windowhasfocus=false;return false})},receive:function(a){if(RealtimeUpdate.isNoticeVisible(a.id)){return}if(RealtimeUpdate._paused===false){RealtimeUpdate.purgeLastNoticeItem();RealtimeUpdate.insertNoticeItem(a)}else{RealtimeUpdate._queuedNotices.push(a);RealtimeUpdate.updateQueuedCounter()}RealtimeUpdate.updateWindowCounter()},insertNoticeItem:function(a){if(RealtimeUpdate.isNoticeVisible(a.id)){return}RealtimeUpdate.makeNoticeItem(a,function(b){var c=$(b).attr("id");var d=$("#notices_primary .notices:first");var j=true;var e=d.hasClass("threaded-notices");if(e&&a.in_reply_to_status_id){var g=$("#notice-"+a.in_reply_to_status_id);if(g.length==0){}else{var h=g.closest(".notices");if(h.hasClass("threaded-replies")){g=h.closest(".notice")}d=g.find(".threaded-replies");if(d.length==0){d=$('<ul class="notices threaded-replies xoxo"></ul>');g.append(d)}j=false}}var i=$(b);if(j){d.prepend(i)}else{var f=d.find("li.notice-reply-placeholder");if(f.length>0){i.insertBefore(f)}else{i.appendTo(d);SN.U.NoticeInlineReplyPlaceholder(g)}}i.css({display:"none"}).fadeIn(1000);SN.U.NoticeReplyTo($("#"+c));SN.U.NoticeWithAttachment($("#"+c))})},isNoticeVisible:function(a){return($("#notice-"+a).length>0)},purgeLastNoticeItem:function(){if($("#notices_primary .notice").length>RealtimeUpdate._maxnotices){$("#notices_primary .notice:last").remove()}},updateWindowCounter:function(){if(RealtimeUpdate._windowhasfocus===false){RealtimeUpdate._updatecounter+=1;document.title="("+RealtimeUpdate._updatecounter+") "+RealtimeUpdate._documenttitle}},removeWindowCounter:function(){document.title=RealtimeUpdate._documenttitle},makeNoticeItem:function(b,c){var a=RealtimeUpdate._showurl.replace("0000000000",b.id);$.get(a,{ajax:1},function(f,h,g){var e=$("li.notice:first",f);if(e.length){var d=document._importNode(e[0],true);c(d)}})},makeFavoriteForm:function(c,b){var a;a='<form id="favor-'+c+'" class="form_favor" method="post" action="'+RealtimeUpdate._favorurl+'"><fieldset><legend>Favor this notice</legend><input name="token-'+c+'" type="hidden" id="token-'+c+'" value="'+b+'"/><input name="notice" type="hidden" id="notice-n'+c+'" value="'+c+'"/><input type="submit" id="favor-submit-'+c+'" name="favor-submit-'+c+'" class="submit" value="Favor" title="Favor this notice"/></fieldset></form>';return a},makeReplyLink:function(c,a){var b;b='<a class="notice_reply" href="'+RealtimeUpdate._replyurl+"?replyto="+a+'" title="Reply to this notice">Reply <span class="notice_id">'+c+"</span></a>";return b},makeRepeatForm:function(c,b){var a;a='<form id="repeat-'+c+'" class="form_repeat" method="post" action="'+RealtimeUpdate._repeaturl+'"><fieldset><legend>Repeat this notice?</legend><input name="token-'+c+'" type="hidden" id="token-'+c+'" value="'+b+'"/><input name="notice" type="hidden" id="notice-'+c+'" value="'+c+'"/><input type="submit" id="repeat-submit-'+c+'" name="repeat-submit-'+c+'" class="submit" value="Yes" title="Repeat this notice"/></fieldset></form>';return a},makeDeleteLink:function(c){var b,a;a=RealtimeUpdate._deleteurl.replace("0000000000",c);b='<a class="notice_delete" href="'+a+'" title="Delete this notice">Delete</a>';return b},initActions:function(a,b,c){$("#notices_primary").prepend('<ul id="realtime_actions"><li id="realtime_playpause"></li><li id="realtime_timeline"></li></ul>');RealtimeUpdate._pluginPath=c;RealtimeUpdate.initPlayPause();RealtimeUpdate.initAddPopup(a,b,RealtimeUpdate._pluginPath)},initPlayPause:function(){if(typeof(localStorage)=="undefined"){RealtimeUpdate.showPause()}else{if(localStorage.getItem("RealtimeUpdate_paused")==="true"){RealtimeUpdate.showPlay()}else{RealtimeUpdate.showPause()}}},showPause:function(){RealtimeUpdate.setPause(false);RealtimeUpdate.showQueuedNotices();RealtimeUpdate.addNoticesHover();$("#realtime_playpause").remove();$("#realtime_actions").prepend('<li id="realtime_playpause"><button id="realtime_pause" class="pause"></button></li>');$("#realtime_pause").text(SN.msg("realtime_pause")).attr("title",SN.msg("realtime_pause_tooltip")).bind("click",function(){RealtimeUpdate.removeNoticesHover();RealtimeUpdate.showPlay();return false})},showPlay:function(){RealtimeUpdate.setPause(true);$("#realtime_playpause").remove();$("#realtime_actions").prepend('<li id="realtime_playpause"><span id="queued_counter"></span> <button id="realtime_play" class="play"></button></li>');$("#realtime_play").text(SN.msg("realtime_play")).attr("title",SN.msg("realtime_play_tooltip")).bind("click",function(){RealtimeUpdate.showPause();return false})},setPause:function(a){RealtimeUpdate._paused=a;if(typeof(localStorage)!="undefined"){localStorage.setItem("RealtimeUpdate_paused",RealtimeUpdate._paused)}},showQueuedNotices:function(){$.each(RealtimeUpdate._queuedNotices,function(a,b){RealtimeUpdate.insertNoticeItem(b)});RealtimeUpdate._queuedNotices=[];RealtimeUpdate.removeQueuedCounter()},updateQueuedCounter:function(){$("#realtime_playpause #queued_counter").html("("+RealtimeUpdate._queuedNotices.length+")")},removeQueuedCounter:function(){$("#realtime_playpause #queued_counter").empty()},addNoticesHover:function(){$("#notices_primary .notices").hover(function(){if(RealtimeUpdate._paused===false){RealtimeUpdate.showPlay()}},function(){if(RealtimeUpdate._paused===true){RealtimeUpdate.showPause()}})},removeNoticesHover:function(){$("#notices_primary .notices").unbind()},initAddPopup:function(a,b,c){$("#realtime_timeline").append('<button id="realtime_popup"></button>');$("#realtime_popup").text(SN.msg("realtime_popup")).attr("title",SN.msg("realtime_popup_tooltip")).bind("click",function(){window.open(a,"","toolbar=no,resizable=yes,scrollbars=yes,status=no,menubar=no,personalbar=no,location=no,width=500,height=550");return false})},initPopupWindow:function(){$(".notices .entry-title a, .notices .entry-content a").bind("click",function(){window.open(this.href,"");return false});$("#showstream .entity_profile").css({width:"69%"})}};
\ No newline at end of file
diff --git a/plugins/SearchSub/SearchSub.php b/plugins/SearchSub/SearchSub.php
new file mode 100644 (file)
index 0000000..cbf64d3
--- /dev/null
@@ -0,0 +1,140 @@
+<?php
+/**
+ * Data class to store local search subscriptions
+ *
+ * PHP version 5
+ *
+ * @category SearchSubPlugin
+ * @package  StatusNet
+ * @author   Brion Vibber <brion@status.net>
+ * @license  http://www.fsf.org/licensing/licenses/agpl.html AGPLv3
+ * @link     http://status.net/
+ *
+ * StatusNet - the distributed open-source microblogging tool
+ * Copyright (C) 2011, StatusNet, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.     See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+if (!defined('STATUSNET')) {
+    exit(1);
+}
+
+/**
+ * For storing the search subscriptions
+ *
+ * @category PollPlugin
+ * @package  StatusNet
+ * @author   Brion Vibber <brion@status.net>
+ * @license  http://www.fsf.org/licensing/licenses/agpl.html AGPLv3
+ * @link     http://status.net/
+ *
+ * @see      DB_DataObject
+ */
+
+class SearchSub extends Managed_DataObject
+{
+    public $__table = 'searchsub'; // table name
+    public $search;         // text
+    public $profile_id;  // int -> profile.id
+    public $created;     // datetime
+
+    /**
+     * Get an instance by key
+     *
+     * This is a utility method to get a single instance with a given key value.
+     *
+     * @param string $k Key to use to lookup (usually 'user_id' for this class)
+     * @param mixed  $v Value to lookup
+     *
+     * @return SearchSub object found, or null for no hits
+     *
+     */
+    function staticGet($k, $v=null)
+    {
+        return Memcached_DataObject::staticGet('SearchSub', $k, $v);
+    }
+
+    /**
+     * Get an instance by compound key
+     *
+     * This is a utility method to get a single instance with a given set of
+     * key-value pairs. Usually used for the primary key for a compound key; thus
+     * the name.
+     *
+     * @param array $kv array of key-value mappings
+     *
+     * @return SearchSub object found, or null for no hits
+     *
+     */
+    function pkeyGet($kv)
+    {
+        return Memcached_DataObject::pkeyGet('SearchSub', $kv);
+    }
+
+    /**
+     * The One True Thingy that must be defined and declared.
+     */
+    public static function schemaDef()
+    {
+        return array(
+            'description' => 'SearchSubPlugin search subscription records',
+            'fields' => array(
+                'search' => array('type' => 'varchar', 'length' => 64, 'not null' => true, 'description' => 'hash search associated with this subscription'),
+                'profile_id' => array('type' => 'int', 'not null' => true, 'description' => 'profile ID of subscribing user'),
+                'created' => array('type' => 'datetime', 'not null' => true, 'description' => 'date this record was created'),
+            ),
+            'primary key' => array('search', 'profile_id'),
+            'foreign keys' => array(
+                'searchsub_profile_id_fkey' => array('profile', array('profile_id' => 'id')),
+            ),
+            'indexes' => array(
+                'searchsub_created_idx' => array('created'),
+                'searchsub_profile_id_tag_idx' => array('profile_id', 'search'),
+            ),
+        );
+    }
+
+    /**
+     * Start a search subscription!
+     *
+     * @param profile $profile subscriber
+     * @param string $search subscribee
+     * @return SearchSub
+     */
+    static function start(Profile $profile, $search)
+    {
+        $ts = new SearchSub();
+        $ts->search = $search;
+        $ts->profile_id = $profile->id;
+        $ts->created = common_sql_now();
+        $ts->insert();
+        return $ts;
+    }
+
+    /**
+     * End a search subscription!
+     *
+     * @param profile $profile subscriber
+     * @param string $search subscribee
+     */
+    static function cancel(Profile $profile, $search)
+    {
+        $ts = SearchSub::pkeyGet(array('search' => $search,
+                                    'profile_id' => $profile->id));
+        if ($ts) {
+            $ts->delete();
+        }
+    }
+}
diff --git a/plugins/SearchSub/SearchSubPlugin.php b/plugins/SearchSub/SearchSubPlugin.php
new file mode 100644 (file)
index 0000000..130600a
--- /dev/null
@@ -0,0 +1,212 @@
+<?php
+/**
+ * StatusNet - the distributed open-source microblogging tool
+ * Copyright (C) 2011, StatusNet, Inc.
+ *
+ * A plugin to enable local tab subscription
+ *
+ * PHP version 5
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * @category  SearchSubPlugin
+ * @package   StatusNet
+ * @author    Brion Vibber <brion@status.net>
+ * @copyright 2011 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
+ * @link      http://status.net/
+ */
+
+if (!defined('STATUSNET')) {
+    exit(1);
+}
+
+/**
+ * SearchSub plugin main class
+ *
+ * @category  SearchSubPlugin
+ * @package   StatusNet
+ * @author    Brion Vibber <brionv@status.net>
+ * @copyright 2011 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
+ * @link      http://status.net/
+ */
+class SearchSubPlugin extends Plugin
+{
+    const VERSION         = '0.1';
+
+    /**
+     * Database schema setup
+     *
+     * @see Schema
+     *
+     * @return boolean hook value; true means continue processing, false means stop.
+     */
+    function onCheckSchema()
+    {
+        $schema = Schema::get();
+        $schema->ensureTable('searchsub', SearchSub::schemaDef());
+        return true;
+    }
+
+    /**
+     * Load related modules when needed
+     *
+     * @param string $cls Name of the class to be loaded
+     *
+     * @return boolean hook value; true means continue processing, false means stop.
+     */
+    function onAutoload($cls)
+    {
+        $dir = dirname(__FILE__);
+
+        switch ($cls)
+        {
+        case 'SearchSub':
+            include_once $dir.'/'.$cls.'.php';
+            return false;
+        case 'SearchsubAction':
+        case 'SearchunsubAction':
+        case 'SearchSubForm':
+        case 'SearchUnsubForm':
+            include_once $dir.'/'.strtolower($cls).'.php';
+            return false;
+        default:
+            return true;
+        }
+    }
+
+    /**
+     * Map URLs to actions
+     *
+     * @param Net_URL_Mapper $m path-to-action mapper
+     *
+     * @return boolean hook value; true means continue processing, false means stop.
+     */
+    function onRouterInitialized($m)
+    {
+        $m->connect('search/:search/subscribe',
+                    array('action' => 'searchsub'),
+                    array('search' => Router::REGEX_TAG));
+        $m->connect('search/:search/unsubscribe',
+                    array('action' => 'searchunsub'),
+                    array('search' => Router::REGEX_TAG));
+
+        return true;
+    }
+
+    /**
+     * Plugin version data
+     *
+     * @param array &$versions array of version data
+     *
+     * @return value
+     */
+    function onPluginVersion(&$versions)
+    {
+        $versions[] = array('name' => 'SearchSub',
+                            'version' => self::VERSION,
+                            'author' => 'Brion Vibber',
+                            'homepage' => 'http://status.net/wiki/Plugin:SearchSub',
+                            'rawdescription' =>
+                            // TRANS: Plugin description.
+                            _m('Plugin to allow following all messages with a given search.'));
+        return true;
+    }
+
+    /**
+     * Hook inbox delivery setup so search subscribers receive all
+     * notices with that search in their inbox.
+     *
+     * Currently makes no distinction between local messages and
+     * remote ones which happen to come in to the system. Remote
+     * notices that don't come in at all won't ever reach this.
+     *
+     * @param Notice $notice
+     * @param array $ni in/out map of profile IDs to inbox constants
+     * @return boolean hook result
+     */
+    function onStartNoticeWhoGets(Notice $notice, array &$ni)
+    {
+        // Warning: this is potentially very slow
+        // with a lot of searches!
+        $sub = new SearchSub();
+        $sub->groupBy('search');
+        $sub->find();
+        while ($sub->fetch()) {
+            $search = $sub->search;
+
+            if ($this->matchSearch($notice, $search)) {
+                // Match? Find all those who subscribed to this
+                // search term and get our delivery on...
+                $searchsub = new SearchSub();
+                $searchsub->search = $search;
+                $searchsub->find();
+
+                while ($searchsub->fetch()) {
+                    // These constants are currently not actually used, iirc
+                    $ni[$searchsub->profile_id] = NOTICE_INBOX_SOURCE_SUB;
+                }
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Does the given notice match the given fulltext search query?
+     *
+     * Warning: not guaranteed to match other search engine behavior, etc.
+     * Currently using a basic case-insensitive substring match, which
+     * probably fits with the 'LIKE' search but not the default MySQL
+     * or Sphinx search backends.
+     *
+     * @param Notice $notice
+     * @param string $search 
+     * @return boolean
+     */
+    function matchSearch(Notice $notice, $search)
+    {
+        return (mb_stripos($notice->content, $search) !== false);
+    }
+
+    /**
+     *
+     * @param NoticeSearchAction $action
+     * @param string $q
+     * @param Notice $notice
+     * @return boolean hook result
+     */
+    function onStartNoticeSearchShowResults($action, $q, $notice)
+    {
+        $user = common_current_user();
+        if ($user) {
+            $search = $q;
+            $searchsub = SearchSub::pkeyGet(array('search' => $search,
+                                                  'profile_id' => $user->id));
+            if ($searchsub) {
+                $form = new SearchUnsubForm($action, $search);
+            } else {
+                $form = new SearchSubForm($action, $search);
+            }
+            $action->elementStart('div', 'entity_actions');
+            $action->elementStart('ul');
+            $action->elementStart('li', 'entity_subscribe');
+            $form->show();
+            $action->elementEnd('li');
+            $action->elementEnd('ul');
+            $action->elementEnd('div');
+        }
+        return true;
+    }
+}
diff --git a/plugins/SearchSub/searchsubaction.php b/plugins/SearchSub/searchsubaction.php
new file mode 100644 (file)
index 0000000..67bc178
--- /dev/null
@@ -0,0 +1,149 @@
+<?php
+/**
+ * StatusNet - the distributed open-source microblogging tool
+ * Copyright (C) 2008-2011, StatusNet, Inc.
+ *
+ * Search subscription action.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * PHP version 5
+ *
+ * @category  Action
+ * @package   StatusNet
+ * @author    Brion Vibber <brion@status.net>
+ * @author    Evan Prodromou <evan@status.net>
+ * @copyright 2008-2010 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
+ * @link      http://status.net/
+ */
+
+if (!defined('STATUSNET')) {
+    exit(1);
+}
+
+/**
+ * Search subscription action
+ *
+ * Takes parameters:
+ *
+ *    - token: session token to prevent CSRF attacks
+ *    - ajax: boolean; whether to return Ajax or full-browser results
+ *
+ * Only works if the current user is logged in.
+ *
+ * @category  Action
+ * @package   StatusNet
+ * @author    Evan Prodromou <evan@status.net>
+ * @author    Brion Vibber <brion@status.net>
+ * @copyright 2008-2011 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
+ * @link      http://status.net/
+ */
+class SearchsubAction extends Action
+{
+    var $user;
+    var $search;
+
+    /**
+     * Check pre-requisites and instantiate attributes
+     *
+     * @param Array $args array of arguments (URL, GET, POST)
+     *
+     * @return boolean success flag
+     */
+    function prepare($args)
+    {
+        parent::prepare($args);
+        if ($this->boolean('ajax')) {
+            StatusNet::setApi(true);
+        }
+
+        // Only allow POST requests
+
+        if ($_SERVER['REQUEST_METHOD'] != 'POST') {
+            // TRANS: Client error displayed trying to perform any request method other than POST.
+            // TRANS: Do not translate POST.
+            $this->clientError(_('This action only accepts POST requests.'));
+            return false;
+        }
+
+        // CSRF protection
+
+        $token = $this->trimmed('token');
+
+        if (!$token || $token != common_session_token()) {
+            // TRANS: Client error displayed when the session token is not okay.
+            $this->clientError(_('There was a problem with your session token.'.
+                                 ' Try again, please.'));
+            return false;
+        }
+
+        // Only for logged-in users
+
+        $this->user = common_current_user();
+
+        if (empty($this->user)) {
+            // TRANS: Client error displayed trying to subscribe when not logged in.
+            $this->clientError(_('Not logged in.'));
+            return false;
+        }
+
+        // Profile to subscribe to
+
+        $this->search = $this->arg('search');
+
+        if (empty($this->search)) {
+            // TRANS: Client error displayed trying to subscribe to a non-existing profile.
+            $this->clientError(_('No such profile.'));
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Handle request
+     *
+     * Does the subscription and returns results.
+     *
+     * @param Array $args unused.
+     *
+     * @return void
+     */
+    function handle($args)
+    {
+        // Throws exception on error
+
+        SearchSub::start($this->user->getProfile(),
+                      $this->search);
+
+        if ($this->boolean('ajax')) {
+            $this->startHTML('text/xml;charset=utf-8');
+            $this->elementStart('head');
+            // TRANS: Page title when search subscription succeeded.
+            $this->element('title', null, _m('Subscribed'));
+            $this->elementEnd('head');
+            $this->elementStart('body');
+            $unsubscribe = new SearchUnsubForm($this, $this->search);
+            $unsubscribe->show();
+            $this->elementEnd('body');
+            $this->elementEnd('html');
+        } else {
+            $url = common_local_url('search',
+                                    array('search' => $this->search));
+            common_redirect($url, 303);
+        }
+    }
+}
diff --git a/plugins/SearchSub/searchsubform.php b/plugins/SearchSub/searchsubform.php
new file mode 100644 (file)
index 0000000..8078cdd
--- /dev/null
@@ -0,0 +1,142 @@
+<?php
+/**
+ * StatusNet, the distributed open-source microblogging tool
+ *
+ * Form for subscribing to a search
+ *
+ * PHP version 5
+ *
+ * LICENCE: This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * @category  SearchSubPlugin
+ * @package   StatusNet
+ * @author    Brion Vibber <brion@status.net>
+ * @author    Evan Prodromou <evan@status.net>
+ * @author    Sarven Capadisli <csarven@status.net>
+ * @copyright 2009-2011 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
+ * @link      http://status.net/
+ */
+
+if (!defined('STATUSNET') && !defined('LACONICA')) {
+    exit(1);
+}
+
+/**
+ * Form for subscribing to a user
+ *
+ * @category SearchSubPlugin
+ * @package  StatusNet
+ * @author   Brion Vibber <brion@status.net>
+ * @author   Evan Prodromou <evan@status.net>
+ * @author   Sarven Capadisli <csarven@status.net>
+ * @license  http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
+ * @link     http://status.net/
+ *
+ * @see      UnsubscribeForm
+ */
+
+class SearchSubForm extends Form
+{
+    /**
+     * Name of search to subscribe to
+     */
+
+    var $search = '';
+
+    /**
+     * Constructor
+     *
+     * @param HTMLOutputter $out     output channel
+     * @param string        $search     name of search to subscribe to
+     */
+
+    function __construct($out=null, $search=null)
+    {
+        parent::__construct($out);
+
+        $this->search = $search;
+    }
+
+    /**
+     * ID of the form
+     *
+     * @return int ID of the form
+     */
+
+    function id()
+    {
+        return 'search-subscribe-' . $this->search;
+    }
+
+
+    /**
+     * class of the form
+     *
+     * @return string of the form class
+     */
+
+    function formClass()
+    {
+        // class to match existing styles...
+        return 'form_user_subscribe ajax';
+    }
+
+
+    /**
+     * Action of the form
+     *
+     * @return string URL of the action
+     */
+
+    function action()
+    {
+        return common_local_url('searchsub', array('search' => $this->search));
+    }
+
+
+    /**
+     * Legend of the Form
+     *
+     * @return void
+     */
+    function formLegend()
+    {
+        $this->out->element('legend', null, _m('Subscribe to this search'));
+    }
+
+    /**
+     * Data elements of the form
+     *
+     * @return void
+     */
+
+    function formData()
+    {
+        $this->out->hidden('subscribeto-' . $this->search,
+                           $this->search,
+                           'subscribeto');
+    }
+
+    /**
+     * Action elements
+     *
+     * @return void
+     */
+
+    function formActions()
+    {
+        $this->out->submit('submit', _('Subscribe'), 'submit', null, _m('Subscribe to this search'));
+    }
+}
diff --git a/plugins/SearchSub/searchunsubaction.php b/plugins/SearchSub/searchunsubaction.php
new file mode 100644 (file)
index 0000000..f7f006e
--- /dev/null
@@ -0,0 +1,89 @@
+<?php
+/**
+ * StatusNet - the distributed open-source microblogging tool
+ * Copyright (C) 2008-2011, StatusNet, Inc.
+ *
+ * Search subscription action.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * PHP version 5
+ *
+ * @category  Action
+ * @package   StatusNet
+ * @author    Brion Vibber <brion@status.net>
+ * @author    Evan Prodromou <evan@status.net>
+ * @copyright 2008-2010 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
+ * @link      http://status.net/
+ */
+
+if (!defined('STATUSNET')) {
+    exit(1);
+}
+
+/**
+ * Search unsubscription action
+ *
+ * Takes parameters:
+ *
+ *    - token: session token to prevent CSRF attacks
+ *    - ajax: boolean; whether to return Ajax or full-browser results
+ *
+ * Only works if the current user is logged in.
+ *
+ * @category  Action
+ * @package   StatusNet
+ * @author    Evan Prodromou <evan@status.net>
+ * @author    Brion Vibber <brion@status.net>
+ * @copyright 2008-2011 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
+ * @link      http://status.net/
+ */
+class SearchunsubAction extends SearchsubAction
+{
+    /**
+     * Handle request
+     *
+     * Does the subscription and returns results.
+     *
+     * @param Array $args unused.
+     *
+     * @return void
+     */
+    function handle($args)
+    {
+        // Throws exception on error
+
+        SearchSub::cancel($this->user->getProfile(),
+                       $this->search);
+
+        if ($this->boolean('ajax')) {
+            $this->startHTML('text/xml;charset=utf-8');
+            $this->elementStart('head');
+            // TRANS: Page title when search unsubscription succeeded.
+            $this->element('title', null, _m('Unsubscribed'));
+            $this->elementEnd('head');
+            $this->elementStart('body');
+            $subscribe = new SearchSubForm($this, $this->search);
+            $subscribe->show();
+            $this->elementEnd('body');
+            $this->elementEnd('html');
+        } else {
+            $url = common_local_url('search',
+                                    array('search' => $this->search));
+            common_redirect($url, 303);
+        }
+    }
+}
diff --git a/plugins/SearchSub/searchunsubform.php b/plugins/SearchSub/searchunsubform.php
new file mode 100644 (file)
index 0000000..296b74f
--- /dev/null
@@ -0,0 +1,109 @@
+<?php
+/**
+ * StatusNet, the distributed open-source microblogging tool
+ *
+ * Form for subscribing to a search
+ *
+ * PHP version 5
+ *
+ * LICENCE: This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * @category  SearchSubPlugin
+ * @package   StatusNet
+ * @author    Brion Vibber <brion@status.net>
+ * @author    Evan Prodromou <evan@status.net>
+ * @author    Sarven Capadisli <csarven@status.net>
+ * @copyright 2009-2011 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
+ * @link      http://status.net/
+ */
+
+if (!defined('STATUSNET') && !defined('LACONICA')) {
+    exit(1);
+}
+
+/**
+ * Form for subscribing to a user
+ *
+ * @category SearchSubPlugin
+ * @package  StatusNet
+ * @author   Brion Vibber <brion@status.net>
+ * @author   Evan Prodromou <evan@status.net>
+ * @author   Sarven Capadisli <csarven@status.net>
+ * @license  http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
+ * @link     http://status.net/
+ *
+ * @see      UnsubscribeForm
+ */
+
+class SearchUnsubForm extends SearchSubForm
+{
+    /**
+     * ID of the form
+     *
+     * @return int ID of the form
+     */
+
+    function id()
+    {
+        return 'search-unsubscribe-' . $this->search;
+    }
+
+
+    /**
+     * class of the form
+     *
+     * @return string of the form class
+     */
+
+    function formClass()
+    {
+        // class to match existing styles...
+        return 'form_user_unsubscribe ajax';
+    }
+
+
+    /**
+     * Action of the form
+     *
+     * @return string URL of the action
+     */
+
+    function action()
+    {
+        return common_local_url('searchunsub', array('search' => $this->search));
+    }
+
+
+    /**
+     * Legend of the Form
+     *
+     * @return void
+     */
+    function formLegend()
+    {
+        $this->out->element('legend', null, _m('Unsubscribe from this search'));
+    }
+
+    /**
+     * Action elements
+     *
+     * @return void
+     */
+
+    function formActions()
+    {
+        $this->out->submit('submit', _('Unsubscribe'), 'submit', null, _m('Unsubscribe from this search'));
+    }
+}
index 38a4c40d481aeea69e3f5a15e426221f65d7acb6..a9cb2315b45e040cb1726914b41dafc10e7e1c86 100644 (file)
@@ -35,6 +35,9 @@ class SubMirrorPlugin extends Plugin
     {
         $m->connect('settings/mirror',
                     array('action' => 'mirrorsettings'));
+        $m->connect('settings/mirror/add/:provider',
+                    array('action' => 'mirrorsettings'),
+                    array('provider' => '[A-Za-z0-9_-]+'));
         $m->connect('settings/mirror/add',
                     array('action' => 'addmirror'));
         $m->connect('settings/mirror/edit',
index 8c3a9740f3cd3528dd051ae596c621b362717070..31805c166936088e19c8f9306093384c8cfc1d57 100644 (file)
@@ -59,11 +59,27 @@ class AddMirrorAction extends BaseMirrorAction
     function prepare($args)
     {
         parent::prepare($args);
-        $this->feedurl = $this->validateFeedUrl($this->trimmed('feedurl'));
+        $feedurl = $this->getFeedUrl();
+        $this->feedurl = $this->validateFeedUrl($feedurl);
         $this->profile = $this->profileForFeed($this->feedurl);
         return true;
     }
 
+    function getFeedUrl()
+    {
+        $provider = $this->trimmed('provider');
+        switch ($provider) {
+        case 'feed':
+            return $this->trimmed('feedurl');
+        case 'twitter':
+            $screenie = $this->trimmed('screen_name');
+            $base = 'http://api.twitter.com/1/statuses/user_timeline.atom?screen_name=';
+            return $base . urlencode($screenie);
+        default:
+            throw new Exception('Internal form error: unrecognized feed provider.');
+        }
+    }
+
     function saveMirror()
     {
         if ($this->oprofile->subscribe()) {
index 3e3431103f1a0b030e699b06c50af5d2f7c56297..843dfb92e1600c671866d7c6c1945b304ac1dbf6 100644 (file)
@@ -68,7 +68,7 @@ abstract class BaseMirrorAction extends Action
         if (common_valid_http_url($url)) {
             return $url;
         } else {
-            $this->clientError(_m("Invalid feed URL."));
+            $this->clientError(sprintf(_m("Invalid feed URL: %s"), $url));
         }
     }
 
index 856099afa3c6aadebdb24ab4ff6dab07c9d056e6..90bbf3dffb30f0dd0e3e301ac67a74f7dfb62318 100644 (file)
@@ -65,18 +65,30 @@ class MirrorSettingsAction extends SettingsAction
     function showContent()
     {
         $user = common_current_user();
+        $provider = $this->trimmed('provider');
+        if ($provider) {
+            $this->showAddFeedForm($provider);
+        } else {
+            $this->elementStart('div', array('id' => 'add-mirror'));
+            $this->showAddWizard();
+            $this->elementEnd('div');
 
-        $this->showAddFeedForm();
-
-        $mirror = new SubMirror();
-        $mirror->subscriber = $user->id;
-        if ($mirror->find()) {
-            while ($mirror->fetch()) {
-                $this->showFeedForm($mirror);
+            $mirror = new SubMirror();
+            $mirror->subscriber = $user->id;
+            if ($mirror->find()) {
+                while ($mirror->fetch()) {
+                    $this->showFeedForm($mirror);
+                }
             }
         }
     }
 
+    function showAddWizard()
+    {
+        $form = new AddMirrorWizard($this);
+        $form->show();
+    }
+
     function showFeedForm($mirror)
     {
         $profile = Profile::staticGet('id', $mirror->subscribed);
@@ -88,10 +100,47 @@ class MirrorSettingsAction extends SettingsAction
 
     function showAddFeedForm()
     {
-        $form = new AddMirrorForm($this);
+        switch ($this->arg('provider')) {
+        case 'statusnet':
+            break;
+        case 'twitter':
+            $form = new AddTwitterMirrorForm($this);
+            break;
+        case 'wordpress':
+            break;
+        case 'linkedin':
+            break;
+        case 'feed':
+        default:
+            $form = new AddMirrorForm($this);
+        }
         $form->show();
     }
 
+    /**
+     *
+     * @param array $args
+     *
+     * @todo move the ajax display handling to common code
+     */
+    function handle($args)
+    {
+        if ($this->boolean('ajax')) {
+            header('Content-Type: text/html;charset=utf-8');
+            $this->elementStart('html');
+            $this->elementStart('head');
+            $this->element('title', null, _('Provider add'));
+            $this->elementEnd('head');
+            $this->elementStart('body');
+
+            $this->showAddFeedForm();
+
+            $this->elementEnd('body');
+            $this->elementEnd('html');
+        } else {
+            return parent::handle($args);
+        }
+    }
     /**
      * Handle a POST request
      *
@@ -108,4 +157,16 @@ class MirrorSettingsAction extends SettingsAction
         $nav = new SubGroupNav($this, common_current_user());
         $nav->show();
     }
+
+    function showScripts()
+    {
+        parent::showScripts();
+        $this->script('plugins/SubMirror/js/mirrorsettings.js');
+    }
+
+    function showStylesheets()
+    {
+        parent::showStylesheets();
+        $this->cssLink('plugins/SubMirror/css/mirrorsettings.css');
+    }
 }
diff --git a/plugins/SubMirror/css/mirrorsettings.css b/plugins/SubMirror/css/mirrorsettings.css
new file mode 100644 (file)
index 0000000..c91bb73
--- /dev/null
@@ -0,0 +1,26 @@
+/* undo insane stuff from core styles */
+#add-mirror-wizard img {
+    display: inline;
+}
+
+/* we need #something to override most of the #content crap */
+
+#add-mirror-wizard {
+    margin-left: 20px;
+    margin-right: 20px;
+}
+
+#add-mirror-wizard .provider-list table {
+    width: 100%;
+}
+
+#add-mirror-wizard .provider-heading img {
+    vertical-align: middle;
+}
+#add-mirror-wizard .provider-heading {
+    cursor: pointer;
+}
+#add-mirror-wizard .provider-detail fieldset {
+    margin-top: 8px; /* hack */
+    margin-bottom: 8px; /* hack */
+}
\ No newline at end of file
diff --git a/plugins/SubMirror/images/providers/facebook.png b/plugins/SubMirror/images/providers/facebook.png
new file mode 100644 (file)
index 0000000..13a53aa
Binary files /dev/null and b/plugins/SubMirror/images/providers/facebook.png differ
diff --git a/plugins/SubMirror/images/providers/feed.png b/plugins/SubMirror/images/providers/feed.png
new file mode 100644 (file)
index 0000000..bd1da4f
Binary files /dev/null and b/plugins/SubMirror/images/providers/feed.png differ
diff --git a/plugins/SubMirror/images/providers/linkedin.png b/plugins/SubMirror/images/providers/linkedin.png
new file mode 100644 (file)
index 0000000..82103d1
Binary files /dev/null and b/plugins/SubMirror/images/providers/linkedin.png differ
diff --git a/plugins/SubMirror/images/providers/statusnet.png b/plugins/SubMirror/images/providers/statusnet.png
new file mode 100644 (file)
index 0000000..6edca21
Binary files /dev/null and b/plugins/SubMirror/images/providers/statusnet.png differ
diff --git a/plugins/SubMirror/images/providers/twitter.png b/plugins/SubMirror/images/providers/twitter.png
new file mode 100644 (file)
index 0000000..41dabc8
Binary files /dev/null and b/plugins/SubMirror/images/providers/twitter.png differ
diff --git a/plugins/SubMirror/images/providers/wordpress.png b/plugins/SubMirror/images/providers/wordpress.png
new file mode 100644 (file)
index 0000000..dfafc75
Binary files /dev/null and b/plugins/SubMirror/images/providers/wordpress.png differ
diff --git a/plugins/SubMirror/js/mirrorsettings.js b/plugins/SubMirror/js/mirrorsettings.js
new file mode 100644 (file)
index 0000000..a27abe7
--- /dev/null
@@ -0,0 +1,47 @@
+$(function() {
+    /**
+     * Append 'ajax=1' parameter onto URL.
+     */
+    function ajaxize(url) {
+        if (url.indexOf('?') == '-1') {
+            return url + '?ajax=1';
+        } else {
+            return url + '&ajax=1';
+        }
+    }
+
+    var addMirror = $('#add-mirror');
+    var wizard = $('#add-mirror-wizard');
+    if (wizard.length > 0) {
+        var list = wizard.find('.provider-list');
+        var providers = list.find('.provider-heading');
+        providers.click(function(event) {
+            console.log(this);
+            var targetUrl = $(this).find('a').attr('href');
+            if (targetUrl) {
+                // Make sure we don't accidentally follow the direct link
+                event.preventDefault();
+
+                var node = this;
+                function showNew() {
+                    var detail = $('<div class="provider-detail" style="display: none"></div>').insertAfter(node);
+                    detail.load(ajaxize(targetUrl), function(responseText, testStatus, xhr) {
+                        detail.slideDown('fast', function() {
+                            detail.find('input[type="text"]').focus();
+                        });
+                    });
+                }
+
+                var old = addMirror.find('.provider-detail');
+                if (old.length) {
+                    old.slideUp('fast', function() {
+                        old.remove();
+                        showNew();
+                    });
+                } else {
+                    showNew();
+                }
+            }
+        });
+    }
+});
\ No newline at end of file
index e1d50c272c80954959fd1471b0ee52df5e687f8b..17edbd5e968c1cf8db34fc7ed1ca41ce5392028a 100644 (file)
@@ -49,6 +49,7 @@ class AddMirrorForm extends Form
      */
     function formData()
     {
+        $this->out->hidden('provider', 'feed');
         $this->out->elementStart('fieldset');
 
         $this->out->elementStart('ul');
@@ -67,7 +68,7 @@ class AddMirrorForm extends Form
         $this->out->elementEnd('fieldset');
     }
 
-    private function doInput($id, $name, $label, $value=null, $instructions=null)
+    protected function doInput($id, $name, $label, $value=null, $instructions=null)
     {
         $this->out->element('label', array('for' => $id), $label);
         $attrs = array('name' => $name,
diff --git a/plugins/SubMirror/lib/addmirrorwizard.php b/plugins/SubMirror/lib/addmirrorwizard.php
new file mode 100644 (file)
index 0000000..920db0b
--- /dev/null
@@ -0,0 +1,187 @@
+<?php
+/**
+ * StatusNet, the distributed open-source microblogging tool
+ * PHP version 5
+ *
+ * LICENCE: This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * @package   StatusNet
+ * @copyright 2010-2011 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
+ * @link      http://status.net/
+ */
+
+if (!defined('STATUSNET') && !defined('LACONICA')) {
+    exit(1);
+}
+
+class AddMirrorWizard extends Widget
+{
+    /**
+     * Name of the form
+     *
+     * Sub-classes should overload this with the name of their form.
+     *
+     * @return void
+     */
+    function formLegend()
+    {
+    }
+
+    /**
+     * Visible or invisible data elements
+     *
+     * Display the form fields that make up the data of the form.
+     * Sub-classes should overload this to show their data.
+     *
+     * @return void
+     */
+    function show()
+    {
+        $this->out->elementStart('div', array('id' => 'add-mirror-wizard'));
+
+        $providers = $this->providers();
+        $this->showProviders($providers);
+
+        $this->out->elementEnd('div');
+    }
+
+    function providers()
+    {
+        return array(
+            /*
+            // We could accept hostname & username combos here, or
+            // webfingery combinations as for remote users.
+            array(
+                'id' => 'statusnet',
+                'name' => _m('StatusNet'),
+            ),
+             */
+            // Accepts a Twitter username and pulls their user timeline as a
+            // public Atom feed. Requires a working alternate hub which, one
+            // hopes, is getting timely updates.
+            array(
+                'id' => 'twitter',
+                'name' => _m('Twitter'),
+            ),
+            /*
+            // WordPress was on our list some whiles ago, but not sure
+            // what we can actually do here. Search on Wordpress.com hosted
+            // sites, or ?
+            array(
+                'id' => 'wordpress',
+                'name' => _m('WordPress'),
+            ),
+             */
+            /*
+            // In theory, Facebook lets you pull public updates over RSS,
+            // but the URLs for your own update feed that I can find from
+            // 2009-era websites no longer seem to work and there's no
+            // good current documentation. May not still be available...
+            // Mirroring from an FB account is probably better done with
+            // the dedicated plugin. (As of March 2011)
+            array(
+                'id' => 'facebook',
+                'name' => _m('Facebook'),
+            ),
+             */
+            /*
+            // LinkedIn doesn't currently seem to have public feeds
+            // for users or groups (March 2011)
+            array(
+                'id' => 'linkedin',
+                'name' => _m('LinkedIn'),
+            ),
+             */
+            array(
+                'id' => 'feed',
+                'name' => _m('RSS or Atom feed'),
+            ),
+        );
+    }
+
+    function showProviders(array $providers)
+    {
+        $out = $this->out;
+
+        $out->elementStart('div', 'provider-list');
+        $out->element('h2', null, _m('Select a feed provider'));
+        $out->elementStart('table');
+        foreach ($providers as $provider) {
+            $icon = common_path('plugins/SubMirror/images/providers/' . $provider['id'] . '.png');
+            $targetUrl = common_local_url('mirrorsettings', array('provider' => $provider['id']));
+
+            $out->elementStart('tr', array('class' => 'provider'));
+            $out->elementStart('td');
+
+            $out->elementStart('div', 'provider-heading');
+            $out->element('img', array('src' => $icon));
+            $out->element('a', array('href' => $targetUrl), $provider['name']);
+            $out->elementEnd('div');
+
+            $out->elementEnd('td');
+            $out->elementEnd('tr');
+        }
+        $out->elementEnd('table');
+        $out->elementEnd('div');
+    }
+
+    /**
+     * Buttons for form actions
+     *
+     * Submit and cancel buttons (or whatever)
+     * Sub-classes should overload this to show their own buttons.
+     *
+     * @return void
+     */
+    function formActions()
+    {
+    }
+
+    /**
+     * ID of the form
+     *
+     * Should be unique on the page. Sub-classes should overload this
+     * to show their own IDs.
+     *
+     * @return string ID of the form
+     */
+    function id()
+    {
+        return 'add-mirror-wizard';
+    }
+
+    /**
+     * Action of the form.
+     *
+     * URL to post to. Should be overloaded by subclasses to give
+     * somewhere to post to.
+     *
+     * @return string URL to post to
+     */
+    function action()
+    {
+        return common_local_url('addmirror');
+    }
+
+    /**
+     * Class of the form.
+     *
+     * @return string the form's class
+     */
+    function formClass()
+    {
+        return 'form_settings';
+    }
+}
diff --git a/plugins/SubMirror/lib/addtwittermirrorform.php b/plugins/SubMirror/lib/addtwittermirrorform.php
new file mode 100644 (file)
index 0000000..eb28aa0
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+/**
+ * StatusNet, the distributed open-source microblogging tool
+ * PHP version 5
+ *
+ * LICENCE: This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * @package   StatusNet
+ * @copyright 2010 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
+ * @link      http://status.net/
+ */
+
+if (!defined('STATUSNET') && !defined('LACONICA')) {
+    exit(1);
+}
+
+class AddTwitterMirrorForm extends AddMirrorForm
+{
+
+    /**
+     * Visible or invisible data elements
+     *
+     * Display the form fields that make up the data of the form.
+     * Sub-classes should overload this to show their data.
+     *
+     * @return void
+     */
+    function formData()
+    {
+        $this->out->hidden('provider', 'twitter');
+        $this->out->elementStart('fieldset');
+
+        $this->out->elementStart('ul');
+
+        $this->li();
+        $this->doInput('addmirror-feedurl',
+                       'screen_name',
+                       _m('Twitter username:'),
+                       $this->out->trimmed('screen_name'));
+        $this->unli();
+
+        $this->li();
+        $this->out->submit('addmirror-save', _m('BUTTON','Add feed'));
+        $this->unli();
+        $this->out->elementEnd('ul');
+        $this->out->elementEnd('fieldset');
+    }
+}
diff --git a/plugins/TagSub/TagSub.php b/plugins/TagSub/TagSub.php
new file mode 100644 (file)
index 0000000..a734b4f
--- /dev/null
@@ -0,0 +1,140 @@
+<?php
+/**
+ * Data class to store local tag subscriptions
+ *
+ * PHP version 5
+ *
+ * @category TagSubPlugin
+ * @package  StatusNet
+ * @author   Brion Vibber <brion@status.net>
+ * @license  http://www.fsf.org/licensing/licenses/agpl.html AGPLv3
+ * @link     http://status.net/
+ *
+ * StatusNet - the distributed open-source microblogging tool
+ * Copyright (C) 2011, StatusNet, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.     See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+if (!defined('STATUSNET')) {
+    exit(1);
+}
+
+/**
+ * For storing the tag subscriptions
+ *
+ * @category PollPlugin
+ * @package  StatusNet
+ * @author   Brion Vibber <brion@status.net>
+ * @license  http://www.fsf.org/licensing/licenses/agpl.html AGPLv3
+ * @link     http://status.net/
+ *
+ * @see      DB_DataObject
+ */
+
+class TagSub extends Managed_DataObject
+{
+    public $__table = 'tagsub'; // table name
+    public $tag;         // text
+    public $profile_id;  // int -> profile.id
+    public $created;     // datetime
+
+    /**
+     * Get an instance by key
+     *
+     * This is a utility method to get a single instance with a given key value.
+     *
+     * @param string $k Key to use to lookup (usually 'user_id' for this class)
+     * @param mixed  $v Value to lookup
+     *
+     * @return TagSub object found, or null for no hits
+     *
+     */
+    function staticGet($k, $v=null)
+    {
+        return Memcached_DataObject::staticGet('TagSub', $k, $v);
+    }
+
+    /**
+     * Get an instance by compound key
+     *
+     * This is a utility method to get a single instance with a given set of
+     * key-value pairs. Usually used for the primary key for a compound key; thus
+     * the name.
+     *
+     * @param array $kv array of key-value mappings
+     *
+     * @return TagSub object found, or null for no hits
+     *
+     */
+    function pkeyGet($kv)
+    {
+        return Memcached_DataObject::pkeyGet('TagSub', $kv);
+    }
+
+    /**
+     * The One True Thingy that must be defined and declared.
+     */
+    public static function schemaDef()
+    {
+        return array(
+            'description' => 'TagSubPlugin tag subscription records',
+            'fields' => array(
+                'tag' => array('type' => 'varchar', 'length' => 64, 'not null' => true, 'description' => 'hash tag associated with this subscription'),
+                'profile_id' => array('type' => 'int', 'not null' => true, 'description' => 'profile ID of subscribing user'),
+                'created' => array('type' => 'datetime', 'not null' => true, 'description' => 'date this record was created'),
+            ),
+            'primary key' => array('tag', 'profile_id'),
+            'foreign keys' => array(
+                'tagsub_profile_id_fkey' => array('profile', array('profile_id' => 'id')),
+            ),
+            'indexes' => array(
+                'tagsub_created_idx' => array('created'),
+                'tagsub_profile_id_tag_idx' => array('profile_id', 'tag'),
+            ),
+        );
+    }
+
+    /**
+     * Start a tag subscription!
+     *
+     * @param profile $profile subscriber
+     * @param string $tag subscribee
+     * @return TagSub
+     */
+    static function start(Profile $profile, $tag)
+    {
+        $ts = new TagSub();
+        $ts->tag = $tag;
+        $ts->profile_id = $profile->id;
+        $ts->created = common_sql_now();
+        $ts->insert();
+        return $ts;
+    }
+
+    /**
+     * End a tag subscription!
+     *
+     * @param profile $profile subscriber
+     * @param string $tag subscribee
+     */
+    static function cancel(Profile $profile, $tag)
+    {
+        $ts = TagSub::pkeyGet(array('tag' => $tag,
+                                    'profile_id' => $profile->id));
+        if ($ts) {
+            $ts->delete();
+        }
+    }
+}
diff --git a/plugins/TagSub/TagSubPlugin.php b/plugins/TagSub/TagSubPlugin.php
new file mode 100644 (file)
index 0000000..e51a7a8
--- /dev/null
@@ -0,0 +1,182 @@
+<?php
+/**
+ * StatusNet - the distributed open-source microblogging tool
+ * Copyright (C) 2011, StatusNet, Inc.
+ *
+ * A plugin to enable local tab subscription
+ *
+ * PHP version 5
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * @category  TagSubPlugin
+ * @package   StatusNet
+ * @author    Brion Vibber <brion@status.net>
+ * @copyright 2011 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
+ * @link      http://status.net/
+ */
+
+if (!defined('STATUSNET')) {
+    exit(1);
+}
+
+/**
+ * TagSub plugin main class
+ *
+ * @category  TagSubPlugin
+ * @package   StatusNet
+ * @author    Brion Vibber <brionv@status.net>
+ * @copyright 2011 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
+ * @link      http://status.net/
+ */
+class TagSubPlugin extends Plugin
+{
+    const VERSION         = '0.1';
+
+    /**
+     * Database schema setup
+     *
+     * @see Schema
+     *
+     * @return boolean hook value; true means continue processing, false means stop.
+     */
+    function onCheckSchema()
+    {
+        $schema = Schema::get();
+        $schema->ensureTable('tagsub', TagSub::schemaDef());
+        return true;
+    }
+
+    /**
+     * Load related modules when needed
+     *
+     * @param string $cls Name of the class to be loaded
+     *
+     * @return boolean hook value; true means continue processing, false means stop.
+     */
+    function onAutoload($cls)
+    {
+        $dir = dirname(__FILE__);
+
+        switch ($cls)
+        {
+        case 'TagSub':
+            include_once $dir.'/'.$cls.'.php';
+            return false;
+        case 'TagsubAction':
+        case 'TagunsubAction':
+        case 'TagSubForm':
+        case 'TagUnsubForm':
+            include_once $dir.'/'.strtolower($cls).'.php';
+            return false;
+        default:
+            return true;
+        }
+    }
+
+    /**
+     * Map URLs to actions
+     *
+     * @param Net_URL_Mapper $m path-to-action mapper
+     *
+     * @return boolean hook value; true means continue processing, false means stop.
+     */
+    function onRouterInitialized($m)
+    {
+        $m->connect('tag/:tag/subscribe',
+                    array('action' => 'tagsub'),
+                    array('tag' => Router::REGEX_TAG));
+        $m->connect('tag/:tag/unsubscribe',
+                    array('action' => 'tagunsub'),
+                    array('tag' => Router::REGEX_TAG));
+
+        return true;
+    }
+
+    /**
+     * Plugin version data
+     *
+     * @param array &$versions array of version data
+     *
+     * @return value
+     */
+    function onPluginVersion(&$versions)
+    {
+        $versions[] = array('name' => 'TagSub',
+                            'version' => self::VERSION,
+                            'author' => 'Brion Vibber',
+                            'homepage' => 'http://status.net/wiki/Plugin:TagSub',
+                            'rawdescription' =>
+                            // TRANS: Plugin description.
+                            _m('Plugin to allow following all messages with a given tag.'));
+        return true;
+    }
+
+    /**
+     * Hook inbox delivery setup so tag subscribers receive all
+     * notices with that tag in their inbox.
+     *
+     * Currently makes no distinction between local messages and
+     * remote ones which happen to come in to the system. Remote
+     * notices that don't come in at all won't ever reach this.
+     *
+     * @param Notice $notice
+     * @param array $ni in/out map of profile IDs to inbox constants
+     * @return boolean hook result
+     */
+    function onStartNoticeWhoGets(Notice $notice, array &$ni)
+    {
+        foreach ($notice->getTags() as $tag) {
+            $tagsub = new TagSub();
+            $tagsub->tag = $tag;
+            $tagsub->find();
+
+            while ($tagsub->fetch()) {
+                // These constants are currently not actually used, iirc
+                $ni[$tagsub->profile_id] = NOTICE_INBOX_SOURCE_SUB;
+            }
+        }
+        return true;
+    }
+
+    /**
+     *
+     * @param TagAction $action
+     * @return boolean hook result
+     */
+    function onStartTagShowContent(TagAction $action)
+    {
+        $user = common_current_user();
+        if ($user) {
+            $tag = $action->trimmed('tag');
+            $tagsub = TagSub::pkeyGet(array('tag' => $tag,
+                                            'profile_id' => $user->id));
+            if ($tagsub) {
+                $form = new TagUnsubForm($action, $tag);
+            } else {
+                $form = new TagSubForm($action, $tag);
+            }
+            $action->elementStart('div', 'entity_actions');
+            $action->elementStart('ul');
+            $action->elementStart('li', 'entity_subscribe');
+            $form->show();
+            $action->elementEnd('li');
+            $action->elementEnd('ul');
+            $action->elementEnd('div');
+        }
+        return true;
+    }
+}
diff --git a/plugins/TagSub/tagsubaction.php b/plugins/TagSub/tagsubaction.php
new file mode 100644 (file)
index 0000000..2e4e25d
--- /dev/null
@@ -0,0 +1,149 @@
+<?php
+/**
+ * StatusNet - the distributed open-source microblogging tool
+ * Copyright (C) 2008-2011, StatusNet, Inc.
+ *
+ * Tag subscription action.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * PHP version 5
+ *
+ * @category  Action
+ * @package   StatusNet
+ * @author    Brion Vibber <brion@status.net>
+ * @author    Evan Prodromou <evan@status.net>
+ * @copyright 2008-2010 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
+ * @link      http://status.net/
+ */
+
+if (!defined('STATUSNET')) {
+    exit(1);
+}
+
+/**
+ * Tag subscription action
+ *
+ * Takes parameters:
+ *
+ *    - token: session token to prevent CSRF attacks
+ *    - ajax: boolean; whether to return Ajax or full-browser results
+ *
+ * Only works if the current user is logged in.
+ *
+ * @category  Action
+ * @package   StatusNet
+ * @author    Evan Prodromou <evan@status.net>
+ * @author    Brion Vibber <brion@status.net>
+ * @copyright 2008-2011 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
+ * @link      http://status.net/
+ */
+class TagsubAction extends Action
+{
+    var $user;
+    var $tag;
+
+    /**
+     * Check pre-requisites and instantiate attributes
+     *
+     * @param Array $args array of arguments (URL, GET, POST)
+     *
+     * @return boolean success flag
+     */
+    function prepare($args)
+    {
+        parent::prepare($args);
+        if ($this->boolean('ajax')) {
+            StatusNet::setApi(true);
+        }
+
+        // Only allow POST requests
+
+        if ($_SERVER['REQUEST_METHOD'] != 'POST') {
+            // TRANS: Client error displayed trying to perform any request method other than POST.
+            // TRANS: Do not translate POST.
+            $this->clientError(_('This action only accepts POST requests.'));
+            return false;
+        }
+
+        // CSRF protection
+
+        $token = $this->trimmed('token');
+
+        if (!$token || $token != common_session_token()) {
+            // TRANS: Client error displayed when the session token is not okay.
+            $this->clientError(_('There was a problem with your session token.'.
+                                 ' Try again, please.'));
+            return false;
+        }
+
+        // Only for logged-in users
+
+        $this->user = common_current_user();
+
+        if (empty($this->user)) {
+            // TRANS: Client error displayed trying to subscribe when not logged in.
+            $this->clientError(_('Not logged in.'));
+            return false;
+        }
+
+        // Profile to subscribe to
+
+        $this->tag = $this->arg('tag');
+
+        if (empty($this->tag)) {
+            // TRANS: Client error displayed trying to subscribe to a non-existing profile.
+            $this->clientError(_('No such profile.'));
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Handle request
+     *
+     * Does the subscription and returns results.
+     *
+     * @param Array $args unused.
+     *
+     * @return void
+     */
+    function handle($args)
+    {
+        // Throws exception on error
+
+        TagSub::start($this->user->getProfile(),
+                      $this->tag);
+
+        if ($this->boolean('ajax')) {
+            $this->startHTML('text/xml;charset=utf-8');
+            $this->elementStart('head');
+            // TRANS: Page title when tag subscription succeeded.
+            $this->element('title', null, _m('Subscribed'));
+            $this->elementEnd('head');
+            $this->elementStart('body');
+            $unsubscribe = new TagUnsubForm($this, $this->tag);
+            $unsubscribe->show();
+            $this->elementEnd('body');
+            $this->elementEnd('html');
+        } else {
+            $url = common_local_url('tag',
+                                    array('tag' => $this->tag));
+            common_redirect($url, 303);
+        }
+    }
+}
diff --git a/plugins/TagSub/tagsubform.php b/plugins/TagSub/tagsubform.php
new file mode 100644 (file)
index 0000000..108558b
--- /dev/null
@@ -0,0 +1,142 @@
+<?php
+/**
+ * StatusNet, the distributed open-source microblogging tool
+ *
+ * Form for subscribing to a tag
+ *
+ * PHP version 5
+ *
+ * LICENCE: This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * @category  TagSubPlugin
+ * @package   StatusNet
+ * @author    Brion Vibber <brion@status.net>
+ * @author    Evan Prodromou <evan@status.net>
+ * @author    Sarven Capadisli <csarven@status.net>
+ * @copyright 2009-2011 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
+ * @link      http://status.net/
+ */
+
+if (!defined('STATUSNET') && !defined('LACONICA')) {
+    exit(1);
+}
+
+/**
+ * Form for subscribing to a user
+ *
+ * @category TagSubPlugin
+ * @package  StatusNet
+ * @author   Brion Vibber <brion@status.net>
+ * @author   Evan Prodromou <evan@status.net>
+ * @author   Sarven Capadisli <csarven@status.net>
+ * @license  http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
+ * @link     http://status.net/
+ *
+ * @see      UnsubscribeForm
+ */
+
+class TagSubForm extends Form
+{
+    /**
+     * Name of tag to subscribe to
+     */
+
+    var $tag = '';
+
+    /**
+     * Constructor
+     *
+     * @param HTMLOutputter $out     output channel
+     * @param string        $tag     name of tag to subscribe to
+     */
+
+    function __construct($out=null, $tag=null)
+    {
+        parent::__construct($out);
+
+        $this->tag = $tag;
+    }
+
+    /**
+     * ID of the form
+     *
+     * @return int ID of the form
+     */
+
+    function id()
+    {
+        return 'tag-subscribe-' . $this->tag;
+    }
+
+
+    /**
+     * class of the form
+     *
+     * @return string of the form class
+     */
+
+    function formClass()
+    {
+        // class to match existing styles...
+        return 'form_user_subscribe ajax';
+    }
+
+
+    /**
+     * Action of the form
+     *
+     * @return string URL of the action
+     */
+
+    function action()
+    {
+        return common_local_url('tagsub', array('tag' => $this->tag));
+    }
+
+
+    /**
+     * Legend of the Form
+     *
+     * @return void
+     */
+    function formLegend()
+    {
+        $this->out->element('legend', null, _m('Subscribe to this tag'));
+    }
+
+    /**
+     * Data elements of the form
+     *
+     * @return void
+     */
+
+    function formData()
+    {
+        $this->out->hidden('subscribeto-' . $this->tag,
+                           $this->tag,
+                           'subscribeto');
+    }
+
+    /**
+     * Action elements
+     *
+     * @return void
+     */
+
+    function formActions()
+    {
+        $this->out->submit('submit', _('Subscribe'), 'submit', null, _m('Subscribe to this tag'));
+    }
+}
diff --git a/plugins/TagSub/tagunsubaction.php b/plugins/TagSub/tagunsubaction.php
new file mode 100644 (file)
index 0000000..26fb9ff
--- /dev/null
@@ -0,0 +1,89 @@
+<?php
+/**
+ * StatusNet - the distributed open-source microblogging tool
+ * Copyright (C) 2008-2011, StatusNet, Inc.
+ *
+ * Tag subscription action.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * PHP version 5
+ *
+ * @category  Action
+ * @package   StatusNet
+ * @author    Brion Vibber <brion@status.net>
+ * @author    Evan Prodromou <evan@status.net>
+ * @copyright 2008-2010 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
+ * @link      http://status.net/
+ */
+
+if (!defined('STATUSNET')) {
+    exit(1);
+}
+
+/**
+ * Tag unsubscription action
+ *
+ * Takes parameters:
+ *
+ *    - token: session token to prevent CSRF attacks
+ *    - ajax: boolean; whether to return Ajax or full-browser results
+ *
+ * Only works if the current user is logged in.
+ *
+ * @category  Action
+ * @package   StatusNet
+ * @author    Evan Prodromou <evan@status.net>
+ * @author    Brion Vibber <brion@status.net>
+ * @copyright 2008-2011 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
+ * @link      http://status.net/
+ */
+class TagunsubAction extends TagsubAction
+{
+    /**
+     * Handle request
+     *
+     * Does the subscription and returns results.
+     *
+     * @param Array $args unused.
+     *
+     * @return void
+     */
+    function handle($args)
+    {
+        // Throws exception on error
+
+        TagSub::cancel($this->user->getProfile(),
+                       $this->tag);
+
+        if ($this->boolean('ajax')) {
+            $this->startHTML('text/xml;charset=utf-8');
+            $this->elementStart('head');
+            // TRANS: Page title when tag unsubscription succeeded.
+            $this->element('title', null, _m('Unsubscribed'));
+            $this->elementEnd('head');
+            $this->elementStart('body');
+            $subscribe = new TagSubForm($this, $this->tag);
+            $subscribe->show();
+            $this->elementEnd('body');
+            $this->elementEnd('html');
+        } else {
+            $url = common_local_url('tag',
+                                    array('tag' => $this->tag));
+            common_redirect($url, 303);
+        }
+    }
+}
diff --git a/plugins/TagSub/tagunsubform.php b/plugins/TagSub/tagunsubform.php
new file mode 100644 (file)
index 0000000..0b44648
--- /dev/null
@@ -0,0 +1,109 @@
+<?php
+/**
+ * StatusNet, the distributed open-source microblogging tool
+ *
+ * Form for subscribing to a tag
+ *
+ * PHP version 5
+ *
+ * LICENCE: This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * @category  TagSubPlugin
+ * @package   StatusNet
+ * @author    Brion Vibber <brion@status.net>
+ * @author    Evan Prodromou <evan@status.net>
+ * @author    Sarven Capadisli <csarven@status.net>
+ * @copyright 2009-2011 StatusNet, Inc.
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
+ * @link      http://status.net/
+ */
+
+if (!defined('STATUSNET') && !defined('LACONICA')) {
+    exit(1);
+}
+
+/**
+ * Form for subscribing to a user
+ *
+ * @category TagSubPlugin
+ * @package  StatusNet
+ * @author   Brion Vibber <brion@status.net>
+ * @author   Evan Prodromou <evan@status.net>
+ * @author   Sarven Capadisli <csarven@status.net>
+ * @license  http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
+ * @link     http://status.net/
+ *
+ * @see      UnsubscribeForm
+ */
+
+class TagUnsubForm extends TagSubForm
+{
+    /**
+     * ID of the form
+     *
+     * @return int ID of the form
+     */
+
+    function id()
+    {
+        return 'tag-unsubscribe-' . $this->tag;
+    }
+
+
+    /**
+     * class of the form
+     *
+     * @return string of the form class
+     */
+
+    function formClass()
+    {
+        // class to match existing styles...
+        return 'form_user_unsubscribe ajax';
+    }
+
+
+    /**
+     * Action of the form
+     *
+     * @return string URL of the action
+     */
+
+    function action()
+    {
+        return common_local_url('tagunsub', array('tag' => $this->tag));
+    }
+
+
+    /**
+     * Legend of the Form
+     *
+     * @return void
+     */
+    function formLegend()
+    {
+        $this->out->element('legend', null, _m('Unsubscribe from this tag'));
+    }
+
+    /**
+     * Action elements
+     *
+     * @return void
+     */
+
+    function formActions()
+    {
+        $this->out->submit('submit', _('Unsubscribe'), 'submit', null, _m('Unsubscribe from this tag'));
+    }
+}
index 22556bb89101936871a43a8c0a380574075b7b6c..7cb5e119111cc4fd3ec4a56d3956661adc3de7f6 100644 (file)
@@ -137,21 +137,46 @@ address {
 #core {
     clear: both;
     margin: 0px;
-    width: 960px;
+    width: 958px;
     border-top: 5px solid #FB6104;
+    border-left: 1px solid #d8dae6;
+    border-right: 1px solid #d8dae6;
 }
 
-#site_nav_local_views {
-    display: block;
+#aside_primary_wrapper {
+    width: 100%;
+    float: left;
+    overflow: hidden;
+    position: relative;
+    background-color: #ececf2;  
+}    
+
+#content_wrapper  {  
+    width: 100%;  
     float: left;
+    position: relative;  
+    right: 239px; 
+    background-color: #fff;  
+    border-right: 1px solid #d8dae6;
+}  
+
+#site_nav_local_views_wrapper  {  
+    width: 100%;  
+    float: left;
+    position: relative;  
+    right: 561px;
+    background-color: #ececf2;  
+    border-right: 1px solid #d8dae6;
+}  
+
+#site_nav_local_views {
     width: 138px;
+    float: left;
+    overflow: hidden;
+    position: relative;
+    left: 800px;
     margin-top: 0px;
-    padding: 10px;
-    padding-top: 22px;
-    background-color: #ececf2; 
-    border-left: 1px solid #d8dae6;
-    border-right: 1px solid #d8dae6;
-    min-height: 800px; /* XXX set up equal column heights! */
+    padding: 22px 10px 40px 10px;
 }
 
 #site_nav_local_views H3 {
@@ -196,8 +221,12 @@ address {
 
 #content {
     width: 520px;
-    margin-right: 0px;
-    padding: 20px;
+    float: left;
+    overflow: hidden;
+    position: relative;
+    left: 801px;
+    margin: 0px;
+    padding: 20px 20px 40px 20px;
 }
 
 /* Input forms */
@@ -357,13 +386,13 @@ address {
 
 #aside_primary {
     width: 218px;
-    padding: 10px;
-    padding-top: 22px;
+    float: left;
+    overflow: hidden;
+    position: relative;
+    left: 802px;
+    padding: 22px 10px 40px 10px;
     margin-top: 0px;
-    background-color: #ececf2; 
-    border-left: 1px solid #d8dae6;
-    border-right: 1px solid #d8dae6;
-    min-height: 800px; /* XXX set up equal column heights! */
+    background: none;
 }
 
 #aside_primary .section {
@@ -665,6 +694,8 @@ div.entry-content a.response:after {
 }
 
 #footer {
+    position: relative;
+    top: -6px;
     color: #000;
     margin-left: 0px;
     margin-right: 0px;