diff --git a/include/class.format.php b/include/class.format.php index cd9b27f24657c5c95ff59699d9706912c38ae36b..33f6b4ebb9c055200fa14091e309120a9bc669a2 100644 --- a/include/class.format.php +++ b/include/class.format.php @@ -714,7 +714,7 @@ class Format { return $text; } - function relativeTime($to, $from=false) { + function relativeTime($to, $from=false, $granularity=1) { $timestamp = $to ?: Misc::gmtime(); if (gettype($timestamp) === 'string') $timestamp = strtotime($timestamp); @@ -724,6 +724,9 @@ class Format { $timeDiff = $from - $timestamp; $absTimeDiff = abs($timeDiff); + // Roll back to the nearest multiple of $granularity + $absTimeDiff -= $absTimeDiff % $granularity; + // within 2 seconds if ($absTimeDiff <= 2) { return $timeDiff >= 0 ? __('just now') : __('now'); @@ -758,7 +761,7 @@ class Format { $days2 = 2 * 86400; if ($absTimeDiff < $days2) { // XXX: yesterday / tomorrow? - return $absTimeDiff >= 0 ? __('1 day ago') : __('in 1 day'); + return $absTimeDiff >= 0 ? __('yesterday') : __('tomorrow'); } // within 29 days diff --git a/include/class.staff.php b/include/class.staff.php index b573eaa6efcc7da49c0cc3a71b979a80cff9f40b..6389e0e3948eff71b3612b666ef185d2f677922c 100644 --- a/include/class.staff.php +++ b/include/class.staff.php @@ -243,6 +243,30 @@ implements AuthenticatedUser, EmailContact, TemplateVariable { function getEmail() { return $this->email; } + /** + * Get either a Gravatar URL or complete image tag for a specified email address. + * + * @param string $email The email address + * @param string $s Size in pixels, defaults to 80px [ 1 - 2048 ] + * @param string $d Default imageset to use [ 404 | mm | identicon | monsterid | wavatar ] + * @param string $r Maximum rating (inclusive) [ g | pg | r | x ] + * @param boole $img True to return a complete IMG tag False for just the URL + * @param array $atts Optional, additional key/value attributes to include in the IMG tag + * @return String containing either just a URL or a complete image tag + * @source http://gravatar.com/site/implement/images/php/ + */ + function get_gravatar($s = 80, $img = false, $atts = array(), $d = 'retro', $r = 'g' ) { + $url = '//www.gravatar.com/avatar/'; + $url .= md5( strtolower( $this->getEmail() ) ); + $url .= "?s=$s&d=$d&r=$r"; + if ( $img ) { + $url = '<img src="' . $url . '"'; + foreach ( $atts as $key => $val ) + $url .= ' ' . $key . '="' . $val . '"'; + $url .= ' />'; + } + return $url; + } function getUserName() { return $this->username; diff --git a/include/class.thread.php b/include/class.thread.php index 285bd9f163144b93cb69794d6639e4ee4d40ac4b..0167e2aacf0ba2c3868dedab3bd72e6e6b03859c 100644 --- a/include/class.thread.php +++ b/include/class.thread.php @@ -1420,15 +1420,29 @@ class ThreadEvent extends VerySimpleModel { 'table' => TICKET_EVENT_TABLE, 'pk' => array('id'), 'joins' => array( - 'thread' => array( - 'constraint' => array('thread_id' => 'Thread.id'), + // Originator of activity + 'agent' => array( + 'constraint' => array( + 'uid' => 'Staff.staff_id', + ), + 'null' => true, ), + // Agent assignee 'staff' => array( 'constraint' => array( - 'uid' => 'Staff.staff_id', + 'staff_id' => 'Staff.staff_id', + ), + 'null' => true, + ), + 'team' => array( + 'constraint' => array( + 'team_id' => 'Team.team_id', ), 'null' => true, ), + 'thread' => array( + 'constraint' => array('thread_id' => 'Thread.id'), + ), 'user' => array( 'constraint' => array( 'uid' => 'User.id', @@ -1462,9 +1476,16 @@ class ThreadEvent extends VerySimpleModel { var $_data; + function getAvatar($size=16) { + if ($this->uid && $this->uid_type == 'S') + return $this->agent->get_gravatar($size); + if ($this->uid && $this->uid_type == 'U') + return $this->user->get_gravatar($size); + } + function getUserName() { if ($this->uid && $this->uid_type == 'S') - return $this->staff->getName(); + return $this->agent->getName(); if ($this->uid && $this->uid_type == 'U') return $this->user->getName(); return $this->username; @@ -1472,10 +1493,10 @@ class ThreadEvent extends VerySimpleModel { function getIcon() { $icons = array( - 'assigned' => 'hand-right', - 'collab' => 'group', - 'created' => 'magic', - 'overdue' => 'time', + 'assigned' => 'hand-right', + 'collab' => 'group', + 'created' => 'magic', + 'overdue' => 'time', ); return @$icons[$this->state] ?: 'chevron-sign-right'; } @@ -1484,6 +1505,7 @@ class ThreadEvent extends VerySimpleModel { static $descs; if (!isset($descs)) $descs = array( + 'assigned' => __('Assignee changed by <b>{username}</b> to <strong>{assignees}</strong> {timestamp}'), 'assigned:staff' => __('<b>{username}</b> assigned this to <strong>{<Staff>data.staff}</strong> {timestamp}'), 'assigned:team' => __('<b>{username}</b> assigned this to <strong>{<Team>data.team}</strong> {timestamp}'), 'assigned:claim' => __('<b>{username}</b> claimed this {timestamp}'), @@ -1497,22 +1519,22 @@ class ThreadEvent extends VerySimpleModel { }, 'collab:add' => function($evt) { $data = $evt->getData(); - $base = __('<b>{username}</b> added %s as collaborators {timestamp}'); + $base = __('<b>{username}</b> added <strong>%s</strong> as collaborators {timestamp}'); $collabs = array(); if ($data['add']) { foreach ($data['add'] as $c) { - $collabs[] = '<b>'.Format::htmlchars($c).'</b>'; + $collabs[] = Format::htmlchars($c); } } return $collabs ? sprintf($base, implode(', ', $collabs)) : 'somebody'; }, - 'created' => __('<strong>Created</strong> by <b>{username}</b> {timestamp}'), + 'created' => __('Created by <b>{username}</b> {timestamp}'), 'edited:owner' => __('<b>{username}</b> changed ownership to {<User>data.owner} {timestamp}'), 'edited:status' => __('<b>{username}</b> changed the status to <strong>{<TicketStatus>data.status}</strong> {timestamp}'), 'overdue' => __('Flagged as overdue by the system {timestamp}'), - 'transferred' => __('<b>{username}</b> transferred this to {dept} {timestamp}'), + 'transferred' => __('<b>{username}</b> transferred this to <strong>{dept}</strong> {timestamp}'), ); $self = $this; $data = $this->getData(); @@ -1529,14 +1551,32 @@ class ThreadEvent extends VerySimpleModel { return preg_replace_callback('/\{(<(?P<type>([^>]+))>)?(?P<key>[^}.]+)(\.(?P<data>[^}]+))?\}/', function ($m) use ($self) { switch ($m['key']) { + case 'assignees': + $assignees = array(); + if ($S = $this->staff) { + $url = $S->get_gravatar(16); + $assignees[] = + "<img class=\"avatar\" src=\"{$url}\"> ".$S->getName(); + } + if ($T = $this->team) { + $assignees[] = $T->getLocalName(); + } + return implode('/', $assignees); case 'username': - return $self->getUserName(); + $name = $self->getUserName(); + if ($url = $self->getAvatar()) + $name = "<img class=\"avatar\" src=\"{$url}\"> ".$name; + return $name; case 'timestamp': return sprintf('<time class="relative" datetime="%s" title="%s">%s</time>', date(DateTime::W3C, Misc::db2gmtime($self->timestamp)), Format::daydatetime($self->timestamp), Format::relativeTime(Misc::db2gmtime($self->timestamp)) ); + case 'agent': + $st = $this->agent; + if ($url = $self->getAvatar()) + $name = "<img class=\"avatar\" src=\"{$url}\"> ".$name; case 'dept': if ($dept = $this->getDept()) return $dept->getLocalName(); diff --git a/include/class.user.php b/include/class.user.php index 02e45ae14b20d5dca88df1c25078c0109ab110ca..fee4c7a91566621a8d9bd37e0cfdfb9a701c53da 100644 --- a/include/class.user.php +++ b/include/class.user.php @@ -267,6 +267,30 @@ implements TemplateVariable { function getEmail() { return new EmailAddress($this->default_email->address); } + /** + * Get either a Gravatar URL or complete image tag for a specified email address. + * + * @param string $email The email address + * @param string $s Size in pixels, defaults to 80px [ 1 - 2048 ] + * @param string $d Default imageset to use [ 404 | mm | identicon | monsterid | wavatar ] + * @param string $r Maximum rating (inclusive) [ g | pg | r | x ] + * @param boole $img True to return a complete IMG tag False for just the URL + * @param array $atts Optional, additional key/value attributes to include in the IMG tag + * @return String containing either just a URL or a complete image tag + * @source http://gravatar.com/site/implement/images/php/ + */ + function get_gravatar($s = 80, $img = false, $atts = array(), $d = 'retro', $r = 'g' ) { + $url = '//www.gravatar.com/avatar/'; + $url .= md5( strtolower( $this->default_email->address ) ); + $url .= "?s=$s&d=$d&r=$r"; + if ( $img ) { + $url = '<img src="' . $url . '"'; + foreach ( $atts as $key => $val ) + $url .= ' ' . $key . '="' . $val . '"'; + $url .= ' />'; + } + return $url; + } function getFullName() { return $this->name; diff --git a/include/staff/templates/thread-entries.tmpl.php b/include/staff/templates/thread-entries.tmpl.php index c9febda653f646ecae1030f3fdc188945ca0a3b3..f15ea1e061997647ba4ec246a630008f412a0c8e 100644 --- a/include/staff/templates/thread-entries.tmpl.php +++ b/include/staff/templates/thread-entries.tmpl.php @@ -5,22 +5,43 @@ $events->rewind(); $event = $events->current(); if (count($entries)) { - foreach ($entries as $entry) { - // Emit all events prior to this entry - while ($event && $event->timestamp <= $entry->created) { - $event->render(ThreadEvent::MODE_STAFF); - $events->next(); - $event = $events->current(); - } - include STAFFINC_DIR . 'templates/thread-entry.tmpl.php'; + // Go through all the entries and bucket them by time frame + $buckets = array(); + $rel = 0; + foreach ($entries as $i=>$E) { + // First item _always_ shows up + if ($i != 0) + // Set relative time resolution to 12 hours + $rel = Format::relativeTime(Misc::db2gmtime($E->created, false, 43200)); + $buckets[$rel][] = $E; } - // Emit all other events - while ($event) { - $event->render(ThreadEvent::MODE_STAFF); - $events->next(); - $event = $events->current(); + + // Go back through the entries and render them on the page + $i = 0; + foreach ($buckets as $rel=>$entries) { + // TODO: Consider adding a date boundary to indicate significant + // changes in dates between thread items. + foreach ($entries as $entry) { + // Emit all events prior to this entry + while ($event && $event->timestamp <= $entry->created) { + $event->render(ThreadEvent::MODE_STAFF); + $events->next(); + $event = $events->current(); + } + include STAFFINC_DIR . 'templates/thread-entry.tmpl.php'; + } + $i++; } } -else { + +// Emit all other events +while ($event) { + $event->render(ThreadEvent::MODE_STAFF); + $events->next(); + $event = $events->current(); +} + +// This should never happen +if (count($entries) + count($events) == 0) { echo '<p><em>'.__('No entries have been posted to this thread.').'</em></p>'; } diff --git a/include/staff/templates/thread-entry.tmpl.php b/include/staff/templates/thread-entry.tmpl.php index d24af728b0cf1608412a8391f763406f54771703..b20f00e11a1df4856e94fcac00c7cfd7da48ae36 100644 --- a/include/staff/templates/thread-entry.tmpl.php +++ b/include/staff/templates/thread-entry.tmpl.php @@ -1,23 +1,23 @@ <?php $entryTypes = array('M'=>'message', 'R'=>'response', 'N'=>'note'); +$user = $entry->getUser() ?: $entry->getStaff(); +$name = $user ? $user->getName() : $entry->poster; +if ($user && ($url = $user->get_gravatar(48))) + $avatar = "<img class=\"avatar\" src=\"{$url}\"> "; ?> -<table class="thread-entry <?php echo $entryTypes[$entry->type]; ?>" cellspacing="0" cellpadding="1" width="940" border="0"> - <tr> - <th colspan="4" width="100%"> - <div> - <span class="pull-left"> - <span style="display:inline-block"><?php - echo Format::datetime($entry->created);?></span> - <span style="display:inline-block;padding:0 1em;max-width: 500px" class="faded title truncate"><?php - echo $entry->title; ?></span> - </span> +<div class="thread-entry <?php echo $entryTypes[$entry->type]; ?> <?php if ($avatar) echo 'avatar'; ?>"> +<?php if ($avatar) { ?> + <span class="<?php echo ($entry->type == 'M') ? 'pull-right' : 'pull-left'; ?> avatar"> +<?php echo $avatar; ?> + </span> +<?php } ?> + <div class="header"> <div class="pull-right"> <?php if ($entry->hasActions()) { $actions = $entry->getActions(); ?> - <span class="action-button pull-right" data-dropdown="#entry-action-more-<?php echo $entry->getId(); ?>"> + <span class="muted-button pull-right" data-dropdown="#entry-action-more-<?php echo $entry->getId(); ?>"> <i class="icon-caret-down"></i> - <span ><i class="icon-cog"></i></span> </span> <div id="entry-action-more-<?php echo $entry->getId(); ?>" class="action-dropdown anchor-right"> <ul class="title"> @@ -34,7 +34,6 @@ $entryTypes = array('M'=>'message', 'R'=>'response', 'N'=>'note'); </ul> </div> <?php } ?> - <span style="vertical-align:middle"> <span style="vertical-align:middle;" class="textra"> <?php if ($entry->flags & ThreadEntry::FLAG_EDITED) { ?> <span class="label label-bare" title="<?php @@ -42,22 +41,28 @@ $entryTypes = array('M'=>'message', 'R'=>'response', 'N'=>'note'); ?>"><?php echo __('Edited'); ?></span> <?php } ?> </span> - <span style="vertical-align:middle;" - class="tmeta faded title"><?php - echo Format::htmlchars($entry->getName()); ?></span> - </span> </div> - </th> - </tr> - <tr><td colspan="4" class="thread-body" id="thread-id-<?php +<?php + echo sprintf(__('<b>%s</b> posted %s'), $name, + sprintf('<time class="relative" datetime="%s" title="%s">%s</time>', + date(DateTime::W3C, Misc::db2gmtime($entry->created)), + Format::daydatetime($entry->created), + Format::relativeTime(Misc::db2gmtime($entry->created)) + ) + ); ?> + <span style="max-width:500px" class="faded title truncate"><?php + echo $entry->title; ?></span> + </span> + </div> + <div class="thread-body" id="thread-id-<?php echo $entry->getId(); ?>"><div><?php - echo $entry->getBody()->toHtml(); ?></div></td></tr> + echo $entry->getBody()->toHtml(); ?></div> + </div> <?php $urls = null; if ($entry->has_attachments && ($urls = $entry->getAttachmentUrls())) { ?> - <tr> - <td class="info" colspan="4"><?php + <div><?php foreach ($entry->attachments as $A) { if ($A->inline) continue; $size = ''; @@ -70,8 +75,7 @@ $entryTypes = array('M'=>'message', 'R'=>'response', 'N'=>'note'); target="_blank"><?php echo Format::htmlchars($A->file->name); ?></a><?php echo $size;?> <?php } ?> - </td> - </tr> <?php + </div> <?php } if ($urls) { ?> <script type="text/javascript"> @@ -82,5 +86,4 @@ $entryTypes = array('M'=>'message', 'R'=>'response', 'N'=>'note'); </script> <?php } ?> -</table> - +</div> diff --git a/include/staff/templates/thread-event.tmpl.php b/include/staff/templates/thread-event.tmpl.php index 8bbf70e5c95b19b0aed8da4629bc4679c7aaa161..f630268cb1af7894bff95051992c78803943c0bc 100644 --- a/include/staff/templates/thread-event.tmpl.php +++ b/include/staff/templates/thread-event.tmpl.php @@ -1,8 +1,8 @@ -<div class="thread-event"> - <span class="type-icon"> - <i class="faded icon-<?php echo $event->getIcon(); ?>"></i> - </span> - <span class="faded" style="display:inline-block"> - <?php echo $event->getDescription(); ?> - </span> +<div class="thread-event <?php if ($event->uid) echo 'action'; ?>"> + <span class="type-icon"> + <i class="faded icon-<?php echo $event->getIcon(); ?>"></i> + </span> + <span class="faded description"> + <?php echo $event->getDescription(); ?> + </span> </div> diff --git a/scp/css/scp.css b/scp/css/scp.css index e2709e74a17034203f0902251e589a903f31af18..0eeaad43d409dbe5194a7b9d6b1f2953d56f91f1 100644 --- a/scp/css/scp.css +++ b/scp/css/scp.css @@ -463,7 +463,7 @@ a.Icon:hover { background:#fff; } -a:not(.re-icon) { +a { color:#184E81; } @@ -854,7 +854,6 @@ h2 .reload { table.thread-entry { margin-top:10px; border:1px solid #aaa; - border-bottom:2px solid #aaa; } #ticket_notes table { @@ -891,17 +890,108 @@ table.thread-entry th, #ticket_notes table th { text-align:right; } -.thread-entry.message th { +.thread-entry { + margin-top: 15px; +} +.thread-entry.avatar { + margin-left: 60px; +} +.thread-entry.message.avatar { + margin-right: 60px; + margin-left: 0; +} +.thread-entry > .avatar { + margin-left: -60px; + display:inline-block; + width:48px; + height:auto; + border-radius: 5px; +} +.thread-entry.message > .avatar { + margin-left: initial; + margin-right: -60px; +} +img.avatar { + border-radius: inherit; +} +.thread-entry .header { + padding: 8px; + border: 1px solid #ccc; + border-color: rgba(0,0,0,0.2); + border-radius: 5px 5px 0 0; +} +.thread-entry.avatar .header:before { + position: absolute; + top: 7px; + right: -9px; + content: ''; + border-top: 9px solid transparent; + border-bottom: 9px solid transparent; + border-left: 9px solid #CCC; + border-left-color: rgba(0,0,0,0.3); + display: inline-block; +} +.thread-entry.avatar .header:after { + position: absolute; + top: 7px; + right: -8px; + content: ''; + border-top: 9px solid transparent; + border-bottom: 9px solid transparent; + display: inline-block; +} + +.thread-entry.avatar .header { + position: relative; +} + +.thread-entry.message .header { background:#C3D9FF; } +.thread-entry.avatar.message .header:after { + border-left:9px solid #C3D9FF; +} -.thread-entry.response th { +.thread-entry.response .header { background:#FFE0B3; } - -.thread-entry.note th { +.thread-entry.avatar.response .header:before, +.thread-entry.avatar.note .header:before { + top: 7px; + left: -9px; + right: initial; + border-left: none; + border-right: 9px solid #CCC; + border-right-color: rgba(0,0,0,0.2); +} +.thread-entry.avatar.response .header:after, +.thread-entry.avatar.note .header:after { + top: 7px; + left: -8px; + right: initial; + border-left: none; + border-right: 9px solid #FFE0B3; +} + +.thread-entry.note .header { background:#FFE; } +.thread-entry.avatar.note .header:after { + border-right-color: #FFE; +} +.thread-entry .header .title { + max-width: 500px; + vertical-align: bottom; + display: inline-block; + margin-left: 15px; +} + +.thread-body { + border: 1px solid #ddd; + border-top: none; + border-bottom:2px solid #aaa; + border-radius: 0 0 5px 5px; +} #ticket_notes table td { padding:5px; @@ -1836,6 +1926,23 @@ div.selected-signature .inner { top: 4px; right: 5px; } +.muted-button:hover { + border: 1px solid #aaa; + border: 1px solid rgba(0,0,0,0.3); + cursor: pointer; + background: rgba(255,255,255,0.1); + color: black; +} +.muted-button { + border-radius: 5px; + padding: 1px 5px; + margin: -1px 0 -1px 5px; + border: 1px solid rgba(0,0,0,0.15); + color: #666; + color: rgba(0,0,0,0.5); + background-color: rgba(0,0,0,0.1); + background: linear-gradient(0, rgba(0,0,0,0.1), rgba(255,255,255,0.1)); +} .sortable-rows tr td:hover { cursor: move; @@ -2230,15 +2337,39 @@ td.indented { } .thread-event { - padding: 15px 5px 5px; + padding: 15px 2px 0; +} +.thread-event.action { + margin-left: 60px; } -.thread-event .type-icon { +.type-icon { border-radius: 8px; background-color: #f4f4f4; - padding: 5px 8px; + padding: 4px 6px; margin-right: 5px; text-align: center; display: inline-block; - font-size: 1.2em; + font-size: 1.1em; border: 1px solid #eee; + vertical-align: top; +} +.type-icon.dark { + border-color: #666; + background-color: #949494; +} +.thread-event img.avatar { + vertical-align: middle; + border-radius: 3px; + width: auto; + max-height: 24px; + margin: -3px 3px 0; +} +.thread-event .description { + margin-left: -30px; + padding-top: 6px; + padding-left: 30px; + display: inline-block; + width: 822px; + width: calc(100% - 46px); + line-height: 1.4em; } diff --git a/scp/js/scp.js b/scp/js/scp.js index d1c73fc8bf47f33a938b5194803683a234a90534..52ca3e4a4f5196e09c6ff6f77081e0a6cfa7b242 100644 --- a/scp/js/scp.js +++ b/scp/js/scp.js @@ -578,6 +578,15 @@ $(document).on('focus', 'form.spellcheck textarea, form.spellcheck input[type=te $(this).attr({'spellcheck':'true', 'lang': lang}); }); +$(document).on('click', '.thread-entry-group a', function() { + var inner = $(this).parent().find('.thread-entry-group-inner'); + if (inner.is(':visible')) + inner.slideUp(); + else + inner.slideDown(); + return false; +}); + $.toggleOverlay = function (show) { if (typeof(show) === 'undefined') { return $.toggleOverlay(!$('#overlay').is(':visible'));