- Gemfile
gem 'rmagick'
and
bundle
- app/jobs/avatar_creation_job.rb
class AvatarCreationJob < ApplicationJob
queue_as :default
def perform(user_id, size = 100, avatar_url = nil)
Users::AvatarService.call(user_id, size, avatar_url)
end
end
PS Check if the Services path is included in the Ruby on Rails autoloader For example
config.autoload_paths += %W(#{config.root}/app/services)
3.1) app/services/application_service.rb
class ApplicationService
def call
raise NotImplementedError
end
class << self
def call(*)
new(*).call
end
end
end
3.2) app/services/users/avatar_service.rb
class Users::AvatarService < ApplicationService
include ActionView::RecordIdentifier
attr_reader :user, :size, :avatar_url
TAILWIND_COLOR_CODES = { 'blue' => '3490dc', 'green' => '38c172',
'red' => 'e3342f', 'yellow' => 'ffed4a',
'indigo' => '6574cd', 'purple' => '9561e2',
'pink' => 'f66d9b', 'teal' => '4dc0b5',
'cyan' => '6cb2eb', 'gray' => '6b7280',
'amber' => 'f6993f', 'lime' => '84cc16',
'emerald' => '10b981', 'rose' => 'ef4444',
'fuchsia' => 'd946ef', 'violet' => '8b5cf6',
'light-blue' => '60a5fa', 'warm-gray' => '6b7280' }.freeze
def initialize(user_id, size, avatar_url)
@user = User.find_by(id: user_id)
@size = size
@avatar_url = avatar_url
end
def call
return if skip_avatar_processing?
process_avatar
update_navbar_avatar(user)
end
private
def skip_avatar_processing?
user.avatar.attached? && avatar_url.present? && user_metadata_matches?(user, avatar_url)
end
def process_avatar
if avatar_url.present? && remote_avatar_exists?(avatar_url)
save_remote_avatar(user, avatar_url, size)
else
generate_and_cache_avatar(user, size)
end
end
def remote_avatar_exists?(url)
uri = URI.parse(url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == 'https')
response = http.request_head(uri.path)
response.is_a?(Net::HTTPSuccess)
rescue StandardError
false
end
def user_metadata_matches?(user, avatar_url)
metadata = user.avatar.blob.metadata || {}
metadata_source = metadata.dig('avatar_source', 'source')
metadata_original_url = metadata.dig('avatar_source', 'original_url')
return false if metadata_source.blank? || metadata_original_url.blank?
metadata_source == 'remote' && metadata_original_url == avatar_url
end
def save_remote_avatar(user, avatar_url, size)
image_data = Net::HTTP.get(URI.parse(avatar_url))
user.avatar.attach(io: StringIO.new(image_data), filename: "avatar_#{user.id}.png", content_type: 'image/png')
update_metadata(user, source: 'remote', original_url: avatar_url)
end
def generate_and_cache_avatar(user, size)
avatar_data = {
initials: user.initials,
bgColor: random_color
}
image_data = generate_avatar_image(avatar_data, size)
user.avatar.attach(io: StringIO.new(image_data), filename: "avatar_#{user.id}.png", content_type: 'image/png')
update_metadata(user, source: 'generated')
end
def generate_avatar_image(data, size)
canvas = Magick::Image.new(size.to_i, size.to_i) do |options|
options.background_color = data[:bgColor]
end
draw = Magick::Draw.new
draw.annotate(canvas, 0, 0, 0, 0, data[:initials]) do |options|
options.font_family = 'Arial'
options.pointsize = size.to_i / 3
options.fill = 'white'
options.gravity = Magick::CenterGravity
end
canvas.format = 'PNG'
canvas.to_blob
end
def random_color
"##{TAILWIND_COLOR_CODES.values.sample}"
end
def update_metadata(user, source_info)
metadata = user.avatar.blob.metadata || {}
metadata['avatar_source'] = source_info
user.avatar.blob.update(metadata:)
end
def update_navbar_avatar(user)
sleep 0.1 # wait for avatar to be attached
Turbo::StreamsChannel.broadcast_update_to([user, :service_info],
target: dom_id(user, :desktop_avatar),
partial: 'shared/user_avatar',
locals: { user: })
end
end
- app/models/user.rb
class User < ApplicationRecord
def self.from_omniauth(omniauth_params)
provider = omniauth_params.provider
uid = omniauth_params.uid
user = User.find_or_initialize_by(provider:, uid:)
user.email = omniauth_params.info.email
user.name = omniauth_params.info.name.presence || omniauth_params.info.nickname
perform_avatar_creation(user, omniauth_params.info.image)
user.tap(&:save)
end
has_one_attached :avatar
def initials
name_initials || email_initials || 'N'
end
def self.perform_avatar_creation(user, avatar_url = nil)
user.image = avatar_url if avatar_url.present?
AvatarCreationJob.perform_later(user.id, 100, avatar_url)
end
private
def name_initials
return if name.blank?
name.split.map { _1[0].capitalize }.join
end
def email_initials
return if email.blank?
email.split('@').first[0].capitalize
end
end
- app/views/layouts/application.html.erb
<body class="flex flex-col min-h-screen">
<%= turbo_stream_from [current_user, :service_info] %>
- app/views/shared/_navbar.html.erb
<div id='<%= dom_id(current_user, :desktop_avatar) %>'>
<%= render 'shared/user_avatar', user: current_user %>
</div>
- app/views/shared/_user_avatar.html.erb
<% if user&.avatar.attached? %>
<%= image_tag url_for(user.avatar),
class: 'h-8 w-8 rounded-full',
data: { id: user.id },
alt: user.email %>
<% end %>
Alternate variant. If the user does not have a saved avatar, it is generated automatically in the decorator.