When the Gravatar service is disabled, display the user's initials as a fallback avatar. This provides a consistent user interface than the generic icon (#29824).

git-svn-id: https://svn.redmine.org/redmine/trunk@23903 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
Marius Balteanu
2025-08-07 19:07:33 +00:00
parent 0b5c1972c3
commit 458072ab7d
15 changed files with 149 additions and 71 deletions

View File

@@ -433,7 +433,7 @@ tr.entry.file td.filename a { margin-left: 26px; }
tr.entry.file td.filename_no_report a { margin-left: 16px; }
tr span.expander, .gantt_subjects div > span.expander {margin-left: 0; cursor: pointer;}
.gantt_subjects div > span .icon-gravatar {float: none;}
.gantt_subjects .avatar {margin-right: 4px;}
.gantt_subjects div.project-name a, .gantt_subjects div.version-name a {margin-left: 4px;}
tr.changeset { height: 20px }
@@ -461,7 +461,7 @@ tr.version:not(.shared) td.name { padding-left: 20px; }
tr.version td.date, tr.version td.status, tr.version td.sharing { text-align: center; white-space:nowrap; }
#principals_for_new_member .icon-user, #users_for_watcher .icon-user {background:transparent;}
#principals_for_new_member svg, #principals_for_new_member img {margin-right: 4px;}
#principals_for_new_member svg, #principals_for_new_member .avatar {margin-right: 4px;}
tr.user td {width:13%;white-space: nowrap;}
td.username, td.firstname, td.lastname, td.email {text-align:left !important;}
@@ -565,9 +565,9 @@ body.controller-gantts fieldset#options > div > div {
td.center {text-align:center;}
#watchers select {width: 95%; display: block;}
#watchers img.gravatar {margin: 0 4px 2px 0;}
#watchers .avatar {margin: 0 4px 2px 0;}
#watchers svg.icon-svg {margin: 0 2px 2px 0;}
#users_for_watcher img.gravatar {padding-bottom: 2px; margin-right: 4px;}
#users_for_watcher .avatar {padding-bottom: 2px; margin-right: 4px;}
#users_for_watcher svg {margin-right: 4px;}
#users_for_watcher span.icon-user {display: inline;}
@@ -1439,7 +1439,7 @@ p.cal.legend span {display:flex;}
.tooltip span.tip{display: none; text-align:left;}
.tooltip span.tip a { color: #169 !important; }
.tooltip span.tip img.gravatar {
.tooltip span.tip .avatar {
float: none;
margin: 0;
}
@@ -1794,9 +1794,6 @@ table.gantt-table td {
}
.gantt_subjects div.issue-subject:hover { background-color:#ffffdd; }
.gantt_selected_column_content > div { padding-left: 3px; box-sizing: border-box; }
.gantt_subjects .issue-subject img.icon-gravatar {
margin: 2px 5px 0px 2px;
}
.gantt_hdr_selected_column_name {
position: absolute;
@@ -2184,21 +2181,16 @@ tr.ui-sortable-helper { border:1px solid #e4e4e4; }
.contextual>*:not(:first-child), .buttons>.icon:not(:first-child), .contextual .journal-actions>*:not(:first-child) { margin-left: 5px; }
img.gravatar {
vertical-align: middle;
border-radius: 20%;
}
div.issue img.gravatar {
div.issue .avatar {
float: left;
margin: 0 12px 6px 0;
}
div.gravatar-with-child {
div.avatar-with-child {
position: relative;
}
div.gravatar-with-child > img.gravatar:nth-child(2) {
div.avatar-with-child > .avatar:nth-child(2) {
position: absolute;
top: 30px;
left: 30px;
@@ -2206,11 +2198,11 @@ div.gravatar-with-child > img.gravatar:nth-child(2) {
border: 2px solid rgba(255, 255, 255, 0.9);
}
h2 img.gravatar, h3 img.gravatar {margin-right: 4px;}
h2 .avatar, h3 .avatar {margin-right: 4px;}
h4 img.gravatar {margin: -2px 4px -4px 0;}
/*# TODO: check where this rule is still used*/
td.username img.gravatar {margin: 0 0.5em 0 0; vertical-align: top;}
#activity dt img.gravatar {margin: 0 1em 0 0;}
/* Used on 12px Gravatar img tags without the icon background */
.icon-gravatar {float: left; margin-right: 4px;}
#activity dt .avatar {margin: 0 1em 0 0;}
#activity dt, .journal {clear: left;}
@@ -2233,6 +2225,98 @@ color: #555; text-shadow: 1px 1px 0 #fff;
img.filecontent.image {background-image: url(/transparent.png);}
/* Avatar styles */
.avatar {
border-radius: 20%;
display: inline-flex;
vertical-align: middle;
}
span[role="img"].avatar {
font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif;
align-items: center;
display: inline-flex;
font-size: calc(24px * .4);
justify-content: center;
user-select: none;
font-weight: 700;
}
.avatar.s13 {
block-size: 13px;
inline-size: 13px;
}
span[role="img"].avatar.s13 {
font-size: calc(16px * .3);
}
.avatar.s16 {
block-size: 16px;
inline-size: 16px;
}
span[role="img"].avatar.s16 {
font-size: calc(16px * .4);
}
.avatar.s22 {
block-size: 22px;
inline-size: 22px;
}
span[role="img"].avatar.s22 {
font-size: calc(22px * .4);
}
.avatar.s24 {
block-size: 24px;
inline-size: 24px;
}
span[role="img"].avatar.s24 {
font-size: calc(24px * .4);
}
.avatar.s40 {
block-size: 40px;
inline-size: 40px;
}
span[role="img"].avatar.s40 {
font-size: calc(40px * .4);
}
.avatar.s50 {
block-size: 50px;
inline-size: 50px;
}
span[role="img"].avatar.s50 {
font-size: calc(50px * .4);
}
.avatar-color-0 {
background-color: #880000;
color: #FFFFFF;
}
.avatar-color-1 {
background-color: #ff0000;
color: #000000;
}
.avatar-color-2 {
background-color: #00ff00;
color: #000000;
}
.avatar-color-3 {
background-color: #008800;
color: #FFFFFF;
}
.avatar-color-4 {
background-color: #0000ff;
color: #FFFFFF;
}
.avatar-color-5 {
background-color: #000088;
color: #FFFFFF;
}
.avatar-color-6 {
background-color: #ff8800;
color: #000000;
}
.avatar-color-7 {
background-color: #ff0088;
color: #000000;
}
/* Reaction styles */
.reaction-button:hover, .reaction-button:active {
text-decoration: none;

View File

@@ -57,6 +57,11 @@ module AvatarsHelper
else
nil
end
elsif user.respond_to?(:initials)
size = options.delete(:size) || GravatarHelper::DEFAULT_OPTIONS[:size]
css_class = "avatar-color-#{user.id % 8} avatar s#{size}"
css_class += " #{options[:class]}" if options[:class]
content_tag('span', user.initials, role: 'img', class: css_class)
else
''
end

View File

@@ -53,6 +53,10 @@ class Group < Principal
name.to_s
end
def initials
name[0, 1]
end
def builtin_type
nil
end

View File

@@ -28,9 +28,9 @@
</div>
<% end %>
<div class="gravatar-with-child">
<div class="avatar-with-child">
<%= author_avatar(@issue.author, :size => "50") %>
<%= assignee_avatar(@issue.assigned_to, :size => "22", :class => "gravatar-child") if @issue.assigned_to %>
<%= assignee_avatar(@issue.assigned_to, :size => "22", :class => "avatar-child") if @issue.assigned_to %>
</div>
<div data-controller="sticky-issue-header">

View File

@@ -36,11 +36,9 @@
<% end %>
<% if User.current.logged? %>
<div class="flyout-menu__avatar <% if !Setting.gravatar_enabled? %>flyout-menu__avatar--no-avatar<% end %>">
<% if Setting.gravatar_enabled? %>
<%= link_to(avatar(User.current, :size => "80"), user_path(User.current)) %>
<% end %>
<%= link_to_user(User.current, :format => :username) %>
<div class="flyout-menu__avatar">
<%= link_to(avatar(User.current, :size => "40"), user_path(User.current)) %>
<%= link_to_user(User.current, :format => :username) %>
</div>
<% end %>

View File

@@ -32,7 +32,7 @@ module GravatarHelper
:title => '',
# The class to assign to the img tag for the gravatar.
:class => 'gravatar',
:class => 'gravatar avatar',
}
# The methods that will be made available to your views.

View File

@@ -731,7 +731,7 @@ module Redmine
css_classes = +''
css_classes << ' issue-overdue' if issue.overdue?
css_classes << ' issue-behind-schedule' if issue.behind_schedule?
css_classes << ' icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to
css_classes << ' icon icon-issue' unless issue.assigned_to
css_classes << ' issue-closed' if issue.closed?
if issue.start_date && issue.due_before && issue.done_ratio
progress_date = calc_progress_date(issue.start_date,
@@ -740,8 +740,8 @@ module Redmine
css_classes << ' over-end-date' if progress_date > self.date_to && issue.done_ratio > 0
end
s = (+"").html_safe
s << view.sprite_icon('issue').html_safe unless Setting.gravatar_enabled? && issue.assigned_to
s << view.assignee_avatar(issue.assigned_to, :size => 13, :class => 'icon-gravatar')
s << view.sprite_icon('issue').html_safe unless issue.assigned_to
s << view.assignee_avatar(issue.assigned_to, :size => 13, :class => 'icon-avatar')
s << view.link_to_issue(issue).html_safe
s << view.content_tag(:input, nil, :type => 'checkbox', :name => 'ids[]',
:value => issue.id, :style => 'display:none;',

View File

@@ -57,7 +57,7 @@ class CalendarsControllerTest < Redmine::ControllerTest
) do
assert_select 'a.issue[href=?]', '/issues/2', :text => 'Feature request #2'
assert_select 'span.tip' do
assert_select 'img[class="gravatar"]'
assert_select 'img[class="gravatar avatar"]'
end
assert_select 'input[name=?][type=?][value=?]', 'ids[]', 'checkbox', '2'
end

View File

@@ -58,7 +58,7 @@ class GanttsControllerTest < Redmine::ControllerTest
# Assert context menu on issues subject and gantt bar
assert_select 'div[class=?]', 'issue-subject hascontextmenu'
assert_select 'div.tooltip.hascontextmenu' do
assert_select 'img[class="gravatar"]'
assert_select 'img[class="gravatar avatar"]'
end
assert_select "form[data-cm-url=?]", '/issues/context_menu'

View File

@@ -2816,7 +2816,7 @@ class IssuesControllerTest < Redmine::ControllerTest
assert_select 'h3', {text: /Watchers \(\d*\)/, count: 0}
end
def test_show_should_display_watchers_with_gravatars
def test_show_should_display_watchers_with_avatars
@request.session[:user_id] = 2
issue = Issue.find(1)
issue.add_watcher User.find(2)
@@ -2824,9 +2824,10 @@ class IssuesControllerTest < Redmine::ControllerTest
with_settings :gravatar_enabled => '1' do
get(:show, :params => {:id => 1})
end
assert_select 'div#watchers ul' do
assert_select 'li.user-2' do
assert_select 'img.gravatar[title=?]', 'John Smith'
assert_select '.avatar[title=?]', 'John Smith'
assert_select 'a[href="/users/2"]'
assert_select 'a[class*=delete]'
end
@@ -8786,31 +8787,27 @@ class IssuesControllerTest < Redmine::ControllerTest
assert_select 'a[href=?][onclick=?]', "/issues/1", "", :text => 'Cancel'
end
def test_show_should_display_author_gravatar_only_when_not_assigned
def test_show_should_display_author_avatar_only_when_not_assigned
issue = Issue.find(1)
assert_nil issue.assigned_to_id
@request.session[:user_id] = 1
with_settings :gravatar_enabled => '1' do
get :show, :params => {:id => issue.id}
assert_select 'div.gravatar-with-child' do
assert_select 'img.gravatar', 1
end
get :show, :params => {:id => issue.id}
assert_select 'div.avatar-with-child' do
assert_select '.avatar', 1
end
end
def test_show_should_display_author_and_assignee_gravatars_when_assigned
def test_show_should_display_author_and_assignee_avatars_when_assigned
issue = Issue.find(1)
issue.assigned_to_id = 2
issue.save!
@request.session[:user_id] = 1
with_settings :gravatar_enabled => '1' do
get :show, :params => {:id => issue.id}
assert_select 'div.gravatar-with-child' do
assert_select 'img.gravatar', 2
assert_select 'img.gravatar-child', 1
end
get :show, :params => {:id => issue.id}
assert_select 'div.avatar-with-child' do
assert_select '.avatar', 2
assert_select '.avatar-child', 1
end
end

View File

@@ -28,7 +28,7 @@ class MessagesControllerTest < Redmine::ControllerTest
get(:show, :params => {:board_id => 1, :id => 1})
assert_response :success
assert_select 'h2', :text => 'First post'
assert_select 'h2', :text => "RAFirst post"
end
def test_show_should_contain_reply_field_tags_for_quoting

View File

@@ -75,7 +75,7 @@ class NewsControllerTest < Redmine::ControllerTest
get(:show, :params => {:id => 1})
assert_response :success
assert_select 'p.breadcrumb a[href=?]', '/projects/ecookbook/news', :text => 'News'
assert_select 'h2', :text => 'eCookbook first release !'
assert_select 'h2', :text => 'JS eCookbook first release !'
end
def test_show_should_show_attachments

View File

@@ -2053,20 +2053,6 @@ class ApplicationHelperTest < Redmine::HelperTest
end
end
def test_principals_check_box_tag_without_avatar
principals = [User.find(1), Group.find(10)]
Setting.gravatar_enabled = '1'
avatar_tags = principals.collect{|p| avatar(p, :size => 16)}
with_settings :gravatar_enabled => '0' do
tags = principals_check_box_tags(name, principals)
principals.each_with_index do |principal, i|
assert_not_include avatar_tags[i], tags
assert_include content_tag('span', principal_icon(principal), :class => "name icon icon-#{principal.class.name.downcase}"), tags
end
end
end
def test_principals_options_for_select_with_users
User.current = nil
users = [User.find(2), User.find(4)]

View File

@@ -63,9 +63,9 @@ class AvatarsHelperTest < Redmine::HelperTest
end
def test_avatar_css_class
# The default class of the img tag should be gravatar
assert_include 'class="gravatar"', avatar('jsmith <jsmith@somenet.foo>')
assert_include 'class="gravatar picture"', avatar('jsmith <jsmith@somenet.foo>', :class => 'picture')
# The default classes of the img tag should be gravatar and avatar
assert_include 'class="gravatar avatar"', avatar('jsmith <jsmith@somenet.foo>')
assert_include 'class="gravatar avatar picture"', avatar('jsmith <jsmith@somenet.foo>', :class => 'picture')
end
def test_avatar_with_initials
@@ -80,9 +80,9 @@ class AvatarsHelperTest < Redmine::HelperTest
end
end
def test_avatar_disabled
def test_avatar_disabled_should_display_user_initials
with_settings :gravatar_enabled => '0' do
assert_equal '', avatar(User.find_by_mail('jsmith@somenet.foo'))
assert_equal "<span role=\"img\" class=\"avatar-color-2 avatar s24\">JS</span>", avatar(User.find_by_mail('jsmith@somenet.foo'))
end
end

View File

@@ -127,20 +127,23 @@ class Redmine::ApiTest::AuthenticationTest < Redmine::ApiTest::Base
assert_response :unauthorized
end
# TODO: check why this test does not use the API endpoint
def test_api_should_accept_switch_user_header_for_admin_user
user = User.find(1)
su = User.find(4)
get '/users/current', :headers => {'X-Redmine-API-Key' => user.api_key, 'X-Redmine-Switch-User' => su.login}
assert_response :success
assert_select 'h2', :text => su.name
assert_select 'h2', :text => "#{su.initials} #{su.name}"
end
# TODO: check why this test does not use the API endpoint
def test_api_should_respond_with_412_when_trying_to_switch_to_a_invalid_user
get '/users/current', :headers => {'X-Redmine-API-Key' => User.find(1).api_key, 'X-Redmine-Switch-User' => 'foobar'}
assert_response :precondition_failed
end
# TODO: check why this test does not use the API endpoint
def test_api_should_respond_with_412_when_trying_to_switch_to_a_locked_user
user = User.find(5)
assert user.locked?
@@ -149,12 +152,13 @@ class Redmine::ApiTest::AuthenticationTest < Redmine::ApiTest::Base
assert_response :precondition_failed
end
# TODO: check why this test does not use the API endpoint
def test_api_should_not_accept_switch_user_header_for_non_admin_user
user = User.find(2)
su = User.find(4)
get '/users/current', :headers => {'X-Redmine-API-Key' => user.api_key, 'X-Redmine-Switch-User' => su.login}
assert_response :success
assert_select 'h2', :text => user.name
assert_select 'h2', :text => "#{user.initials} #{user.name}"
end
end