diff --git a/EVENTS.txt b/EVENTS.txt index 1443a94fbe..54d06655ee 100644 --- a/EVENTS.txt +++ b/EVENTS.txt @@ -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 diff --git a/actions/apigrouplist.php b/actions/apigrouplist.php index 1f6d44363f..f80fbce932 100644 --- a/actions/apigrouplist.php +++ b/actions/apigrouplist.php @@ -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 diff --git a/actions/apitimelineuser.php b/actions/apitimelineuser.php index 66984b5abd..3fe73c691c 100644 --- a/actions/apitimelineuser.php +++ b/actions/apitimelineuser.php @@ -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.')); } diff --git a/actions/noticesearch.php b/actions/noticesearch.php index 4f4c7a05ba..1f43af800d 100644 --- a/actions/noticesearch.php +++ b/actions/noticesearch.php @@ -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() diff --git a/actions/shownotice.php b/actions/shownotice.php index 3978f03ea9..b8927372bb 100644 --- a/actions/shownotice.php +++ b/actions/shownotice.php @@ -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 * diff --git a/classes/Notice.php b/classes/Notice.php index d520f4728f..664e5dab9f 100644 --- a/classes/Notice.php +++ b/classes/Notice.php @@ -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(); diff --git a/lib/action.php b/lib/action.php index 233ac78567..fce59ba8a0 100644 --- a/lib/action.php +++ b/lib/action.php @@ -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'); } /** diff --git a/lib/info.php b/lib/info.php index 395c6522ec..f72bed59d6 100644 --- a/lib/info.php +++ b/lib/info.php @@ -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() diff --git a/plugins/ExtendedProfile/ExtendedProfilePlugin.php b/plugins/ExtendedProfile/ExtendedProfilePlugin.php index 3f541c0008..d1572ce9fd 100644 --- a/plugins/ExtendedProfile/ExtendedProfilePlugin.php +++ b/plugins/ExtendedProfile/ExtendedProfilePlugin.php @@ -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; } diff --git a/plugins/ExtendedProfile/Profile_detail.php b/plugins/ExtendedProfile/Profile_detail.php index 6fd96cca70..96869b0e63 100644 --- a/plugins/ExtendedProfile/Profile_detail.php +++ b/plugins/ExtendedProfile/Profile_detail.php @@ -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) + /** + * 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 + * @param mixed $v Value to lookup + * + * @return User_greeting_count object found, or null for no hits + * + */ + + function staticGet($k, $v=null) { - return parent::staticGet(__CLASS__, $k, $v); + return Memcached_DataObject::staticGet('Profile_detail', $k, $v); } /** - * return table definition for DB_DataObject + * Get an instance by compound 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 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 Bookmark object found, or null for no hits * - * @return array array of column definitions */ - function table() + function pkeyGet($kv) { - 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); + return Memcached_DataObject::pkeyGet('Profile_detail', $kv); } 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 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 - * - * DB_DataObject needs to know about keys that the table has; this function - * defines them. - * - * @return array key definitions - */ - - function keys() - { - return array_keys($this->keyTypes()); - } - - /** - * return key definitions for Memcached_DataObject - * - * Our caching system uses the same key definitions, but uses a different - * method to get them. - * - * @return array key definitions - */ - - function keyTypes() - { - // @fixme keys - // need a sane key for reverse lookup too - return array('id' => 'K'); - } - - function sequenceKey() - { - 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 index 0000000000..d4857429e0 --- /dev/null +++ b/plugins/ExtendedProfile/action/userautocomplete.php @@ -0,0 +1,113 @@ +. + * + * @category Search + * @package StatusNet + * @author Zach Copley + * @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 index 0000000000..3af9bcba4a --- /dev/null +++ b/plugins/ExtendedProfile/css/profiledetail.css @@ -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; +} diff --git a/plugins/ExtendedProfile/extendedprofile.php b/plugins/ExtendedProfile/extendedprofile.php index 7f69f90899..fa632e5073 100644 --- a/plugins/ExtendedProfile/extendedprofile.php +++ b/plugins/ExtendedProfile/extendedprofile.php @@ -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() ), ), ); diff --git a/plugins/ExtendedProfile/extendedprofilewidget.php b/plugins/ExtendedProfile/extendedprofilewidget.php index bf9b4056cd..1ef6440ed6 100644 --- a/plugins/ExtendedProfile/extendedprofilewidget.php +++ b/plugins/ExtendedProfile/extendedprofilewidget.php @@ -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 showFieldValue($name, $field) - { - $this->out->text($name); + 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) + { + $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 index 0000000000..99a3f78a43 --- /dev/null +++ b/plugins/ExtendedProfile/js/profiledetail.js @@ -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 index 836b647a10..0000000000 --- a/plugins/ExtendedProfile/profiledetail.css +++ /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 diff --git a/plugins/ExtendedProfile/profiledetailaction.php b/plugins/ExtendedProfile/profiledetailaction.php index a4bb12956e..a777a28e03 100644 --- a/plugins/ExtendedProfile/profiledetailaction.php +++ b/plugins/ExtendedProfile/profiledetailaction.php @@ -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'); } diff --git a/plugins/ExtendedProfile/profiledetailsettingsaction.php b/plugins/ExtendedProfile/profiledetailsettingsaction.php index 77d755c0b0..7b03f247ed 100644 --- a/plugins/ExtendedProfile/profiledetailsettingsaction.php +++ b/plugins/ExtendedProfile/profiledetailsettingsaction.php @@ -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); + } + } + } diff --git a/plugins/Realtime/README b/plugins/Realtime/README index 9b36d87f37..0c52427eb6 100644 --- a/plugins/Realtime/README +++ b/plugins/Realtime/README @@ -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) diff --git a/plugins/Realtime/RealtimePlugin.php b/plugins/Realtime/RealtimePlugin.php index 246b1f9735..108a6c3b60 100644 --- a/plugins/Realtime/RealtimePlugin.php +++ b/plugins/Realtime/RealtimePlugin.php @@ -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() diff --git a/plugins/Realtime/realtimeupdate.js b/plugins/Realtime/realtimeupdate.js index e615895cab..59e3fe72d7 100644 --- a/plugins/Realtime/realtimeupdate.js +++ b/plugins/Realtime/realtimeupdate.js @@ -44,10 +44,7 @@ */ 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'); + RealtimeUpdate.makeNoticeItem(data, function(noticeItem) { + var noticeItemID = $(noticeItem).attr('id'); - var list = $("#notices_primary .notices:first") - var prepend = true; + 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'); + 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 = $(''); + parent.append(list); + } + prepend = false; } - list = parent.find('.threaded-replies'); - if (list.length == 0) { - list = $(''); - parent.append(list); - } - 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(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/&/g,'&'); - source = data['source'].replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/&/g,'&'); - - ni = "
  • "+ - "
    "+ - ""+ - ""+ - "\""+user['screen_name']+"\"/"+ - ""+user['screen_name']+""+ - ""+ - ""+ - "

    "+html+"

    "+ - "
    "+ - "
    "+ - ""+ - "a few seconds ago"+ - " "+ - ""+ - "from "+ - ""+source+""+ // may have a link - ""; - if (data['conversation_url']) { - ni = ni+" in context"; - } - - if (repeat) { - ru = repeat['user']; - ni = ni + "Repeated by " + - "" + - ""+ ru['screen_name'] + ""; - } - - ni = ni+"
    "; - - ni = ni + "
    "; - - 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+"
    "; - - ni = ni+"
  • "; - 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); + } + }); }, /** diff --git a/plugins/Realtime/realtimeupdate.min.js b/plugins/Realtime/realtimeupdate.min.js index 931de982ef..7e77f90709 100644 --- a/plugins/Realtime/realtimeupdate.min.js +++ b/plugins/Realtime/realtimeupdate.min.js @@ -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=$('');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(/</g,"<").replace(/>/g,">").replace(/"/g,'"').replace(/&/g,"&");source=c.source.replace(/</g,"<").replace(/>/g,">").replace(/"/g,'"').replace(/&/g,"&");ni='
  • '+user.screen_name+''+user.screen_name+'

    '+html+'

    a few seconds ago from '+source+"";if(c.conversation_url){ni=ni+' in context'}if(repeat){ru=repeat.user;ni=ni+'Repeated by '+ru.screen_name+""}ni=ni+"
    ";ni=ni+'
    ';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+"
    ";ni=ni+"
  • ";return ni},makeFavoriteForm:function(c,b){var a;a='
    Favor this notice
    ';return a},makeReplyLink:function(c,a){var b;b='Reply '+c+"";return b},makeRepeatForm:function(c,b){var a;a='
    Repeat this notice?
    ';return a},makeDeleteLink:function(c){var b,a;a=RealtimeUpdate._deleteurl.replace("0000000000",c);b='Delete';return b},initActions:function(a,b,c){$("#notices_primary").prepend('');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('
  • ');$("#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('
  • ');$("#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('');$("#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=$('');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='
    Favor this notice
    ';return a},makeReplyLink:function(c,a){var b;b='Reply '+c+"";return b},makeRepeatForm:function(c,b){var a;a='
    Repeat this notice?
    ';return a},makeDeleteLink:function(c){var b,a;a=RealtimeUpdate._deleteurl.replace("0000000000",c);b='Delete';return b},initActions:function(a,b,c){$("#notices_primary").prepend('');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('
  • ');$("#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('
  • ');$("#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('');$("#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 index 0000000000..cbf64d39cc --- /dev/null +++ b/plugins/SearchSub/SearchSub.php @@ -0,0 +1,140 @@ + + * @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 . + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +/** + * For storing the search subscriptions + * + * @category PollPlugin + * @package StatusNet + * @author Brion Vibber + * @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 index 0000000000..130600a41a --- /dev/null +++ b/plugins/SearchSub/SearchSubPlugin.php @@ -0,0 +1,212 @@ +. + * + * @category SearchSubPlugin + * @package StatusNet + * @author Brion Vibber + * @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 + * @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 index 0000000000..67bc178df6 --- /dev/null +++ b/plugins/SearchSub/searchsubaction.php @@ -0,0 +1,149 @@ +. + * + * PHP version 5 + * + * @category Action + * @package StatusNet + * @author Brion Vibber + * @author Evan Prodromou + * @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 + * @author Brion Vibber + * @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 index 0000000000..8078cdde1b --- /dev/null +++ b/plugins/SearchSub/searchsubform.php @@ -0,0 +1,142 @@ +. + * + * @category SearchSubPlugin + * @package StatusNet + * @author Brion Vibber + * @author Evan Prodromou + * @author Sarven Capadisli + * @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 + * @author Evan Prodromou + * @author Sarven Capadisli + * @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 index 0000000000..f7f006e21c --- /dev/null +++ b/plugins/SearchSub/searchunsubaction.php @@ -0,0 +1,89 @@ +. + * + * PHP version 5 + * + * @category Action + * @package StatusNet + * @author Brion Vibber + * @author Evan Prodromou + * @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 + * @author Brion Vibber + * @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 index 0000000000..296b74f4a1 --- /dev/null +++ b/plugins/SearchSub/searchunsubform.php @@ -0,0 +1,109 @@ +. + * + * @category SearchSubPlugin + * @package StatusNet + * @author Brion Vibber + * @author Evan Prodromou + * @author Sarven Capadisli + * @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 + * @author Evan Prodromou + * @author Sarven Capadisli + * @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')); + } +} diff --git a/plugins/SubMirror/SubMirrorPlugin.php b/plugins/SubMirror/SubMirrorPlugin.php index 38a4c40d48..a9cb2315b4 100644 --- a/plugins/SubMirror/SubMirrorPlugin.php +++ b/plugins/SubMirror/SubMirrorPlugin.php @@ -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', diff --git a/plugins/SubMirror/actions/addmirror.php b/plugins/SubMirror/actions/addmirror.php index 8c3a9740f3..31805c1669 100644 --- a/plugins/SubMirror/actions/addmirror.php +++ b/plugins/SubMirror/actions/addmirror.php @@ -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()) { diff --git a/plugins/SubMirror/actions/basemirror.php b/plugins/SubMirror/actions/basemirror.php index 3e3431103f..843dfb92e1 100644 --- a/plugins/SubMirror/actions/basemirror.php +++ b/plugins/SubMirror/actions/basemirror.php @@ -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)); } } diff --git a/plugins/SubMirror/actions/mirrorsettings.php b/plugins/SubMirror/actions/mirrorsettings.php index 856099afa3..90bbf3dffb 100644 --- a/plugins/SubMirror/actions/mirrorsettings.php +++ b/plugins/SubMirror/actions/mirrorsettings.php @@ -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 index 0000000000..c91bb73b6b --- /dev/null +++ b/plugins/SubMirror/css/mirrorsettings.css @@ -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 index 0000000000..13a53aa63c 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 index 0000000000..bd1da4f914 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 index 0000000000..82103d1f3f 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 index 0000000000..6edca21697 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 index 0000000000..41dabc883e 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 index 0000000000..dfafc75a2f 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 index 0000000000..a27abe7ad5 --- /dev/null +++ b/plugins/SubMirror/js/mirrorsettings.js @@ -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 = $('').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 diff --git a/plugins/SubMirror/lib/addmirrorform.php b/plugins/SubMirror/lib/addmirrorform.php index e1d50c272c..17edbd5e96 100644 --- a/plugins/SubMirror/lib/addmirrorform.php +++ b/plugins/SubMirror/lib/addmirrorform.php @@ -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 index 0000000000..920db0bc9c --- /dev/null +++ b/plugins/SubMirror/lib/addmirrorwizard.php @@ -0,0 +1,187 @@ +. + * + * @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 index 0000000000..eb28aa038f --- /dev/null +++ b/plugins/SubMirror/lib/addtwittermirrorform.php @@ -0,0 +1,60 @@ +. + * + * @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 index 0000000000..a734b4fc5f --- /dev/null +++ b/plugins/TagSub/TagSub.php @@ -0,0 +1,140 @@ + + * @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 . + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +/** + * For storing the tag subscriptions + * + * @category PollPlugin + * @package StatusNet + * @author Brion Vibber + * @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 index 0000000000..e51a7a8b39 --- /dev/null +++ b/plugins/TagSub/TagSubPlugin.php @@ -0,0 +1,182 @@ +. + * + * @category TagSubPlugin + * @package StatusNet + * @author Brion Vibber + * @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 + * @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 index 0000000000..2e4e25d6e1 --- /dev/null +++ b/plugins/TagSub/tagsubaction.php @@ -0,0 +1,149 @@ +. + * + * PHP version 5 + * + * @category Action + * @package StatusNet + * @author Brion Vibber + * @author Evan Prodromou + * @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 + * @author Brion Vibber + * @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 index 0000000000..108558be24 --- /dev/null +++ b/plugins/TagSub/tagsubform.php @@ -0,0 +1,142 @@ +. + * + * @category TagSubPlugin + * @package StatusNet + * @author Brion Vibber + * @author Evan Prodromou + * @author Sarven Capadisli + * @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 + * @author Evan Prodromou + * @author Sarven Capadisli + * @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 index 0000000000..26fb9ffec8 --- /dev/null +++ b/plugins/TagSub/tagunsubaction.php @@ -0,0 +1,89 @@ +. + * + * PHP version 5 + * + * @category Action + * @package StatusNet + * @author Brion Vibber + * @author Evan Prodromou + * @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 + * @author Brion Vibber + * @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 index 0000000000..0b44648071 --- /dev/null +++ b/plugins/TagSub/tagunsubform.php @@ -0,0 +1,109 @@ +. + * + * @category TagSubPlugin + * @package StatusNet + * @author Brion Vibber + * @author Evan Prodromou + * @author Sarven Capadisli + * @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 + * @author Evan Prodromou + * @author Sarven Capadisli + * @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')); + } +} diff --git a/theme/neo/css/display.css b/theme/neo/css/display.css index 22556bb891..7cb5e11911 100644 --- a/theme/neo/css/display.css +++ b/theme/neo/css/display.css @@ -137,21 +137,46 @@ address { #core { clear: both; margin: 0px; - width: 960px; + width: 958px; border-top: 5px solid #FB6104; -} - -#site_nav_local_views { - display: block; - float: left; - width: 138px; - 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! */ +} + +#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: 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;