Около года назад я написал статью о Mailboxer — жемчужине для отправки сообщений в приложениях Rails. Этот драгоценный камень предоставляет хороший набор функциональных возможностей, однако имеет ряд проблем:
- Не хватает надлежащей документации.
- Это довольно сложно, особенно для начинающих программистов. Поскольку он не имеет обширной документации, иногда требуется погрузиться в исходный код, чтобы понять, как работает какой-либо метод.
- Это не активно поддерживается.
- У него есть некоторые ошибки, и пока он активно не развивается, кто знает, когда эти ошибки будут исправлены.
За последние несколько месяцев я получил много вопросов о Mailboxer и поэтому решил объяснить, как создать собственную систему обмена сообщениями для Rails. Конечно, эта система не будет предоставлять все функции Mailboxer, но этого будет более чем достаточно для многих приложений. Когда вы полностью поймете, как работает эта система, вам будет гораздо проще ее усовершенствовать.
Эта статья будет разделена на две части:
- Первая часть будет посвящена подготовке, настройке ассоциаций и созданию контроллеров и представлений. Мы также добавим поддержку Emoji. К концу этой части у вас будет работающая система обмена сообщениями.
- Во второй части объясняется, как использовать ActionCable для реализации системы обмена сообщениями и уведомлениями в режиме реального времени. ActionCable, вероятно, самая ожидаемая особенность Rails 5, и я уже рассмотрел ее некоторое время назад. Кроме того, мы также реализуем функцию «пользователь онлайн».
Исходный код доступен на GitHub .
Рабочая демоверсия доступна по адресу sitepoint-custom-messaging.herokuapp.com .
Аутентификация
Сделайте глубокий вдох и создайте новое приложение Rails:
$ rails new Messager -T
ActionCable был представлен в Rails 5, поэтому некоторые части этой статьи не относятся к более ранним версиям. Тем не менее, концепции not-ActionCable действительны для Rails 3 и 4.
Нам понадобится способ аутентификации пользователей, поэтому давайте перейдем к Devise :
Gemfile
# ...
gem 'devise'
# ...
Выполните следующие команды, чтобы установить Devise и создать необходимые файлы:
$ bundle install
$ rails generate devise:install
$ rails generate devise User
$ rails db:migrate
Теперь модель User
Давайте добавим простое частичное отображение флеш-сообщений и пару ссылок:
общий / _menu.html.erb
<% flash.each do |key, value| %>
<div>
<%= value %>
</div>
<% end %>
<% if user_signed_in? %>
<p>Signed in as <%= current_user.name %> | <%= link_to 'log out', destroy_user_session_path, method: :delete %></p>
<% end %>
<ul>
<li><%= link_to 'Home', root_path %></li>
</ul>
Отобразите этот фрагмент в макете:
макеты / application.html.erb
<%= render 'shared/menu' %>
У наших пользователей на самом деле нет имени, но Devise вводит столбец email
модели / user.rb
def name
email.split('@')[0]
end
Создание моделей и создание ассоциаций
Далее нам потребуются еще две модели и вот их основные поля:
Conversation
-
author_id
-
recevier_id
В качестве примера мы будем использовать систему разговоров Facebook, так что между двумя конкретными пользователями может быть только один разговор.
PersonalMessage
-
body
-
conversation_id
-
user_id
Вот и все. Создайте эти две модели и соответствующие миграции:
$ rails g model Conversation author_id:integer:index receiver_id:integer:index
$ rails g model PersonalMessage body:text conversation:belongs_to user:belongs_to
Настройте первую миграцию, добавив еще один индекс, обеспечивающий уникальность комбинации author_id
receiver_id
дб / мигрирует / xyz_create_conversations.rb
# ...
add_index :conversations, [:author_id, :receiver_id], unique: true
# ...
Применить миграции:
$ rails db:migrate
Теперь сложная часть. Разговоры должны принадлежать автору и получателю, но на самом деле эти две модели являются одной и той же моделью User
Это требует от нас предоставить специальную опцию для метода belongs_to
модели / conversation.rb
# ...
belongs_to :author, class_name: 'User'
belongs_to :receiver, class_name: 'User'
# ...
Чтобы узнать больше об ассоциации Rails, прочитайте эту статью. На стороне пользователя, мы также должны установить
два отношения:
модели / user.rb
# ...
has_many :authored_conversations, class_name: 'Conversation', foreign_key: 'author_id'
has_many :received_conversations, class_name: 'Conversation', foreign_key: 'received_id'
# ...
В этом случае требуется указать, какой внешний ключ использовать, потому что по умолчанию Rails использует имя ассоциации для вывода имени ключа.
Давайте также создадим проверку, чтобы гарантировать, что между двумя одними и теми же пользователями не может быть двух бесед:
модели / conversation.rb
# ...
validates :author, uniqueness: {scope: :receiver}
# ...
PersonalMessage
Установите отношение has_many
модели / conversation.rb
# ...
has_many :personal_messages, -> { order(created_at: :asc) }, dependent: :destroy
# ...
Личное сообщение принадлежит разговору и пользователю:
модели / personal_message.rb
# ...
belongs_to :conversation
belongs_to :user
# ...
Кроме того, пока мы здесь, давайте добавим простое правило проверки:
модели / personal_message.rb
# ...
validates :body, presence: true
# ...
Наконец позаботьтесь о модели User
модели / user.rb
# ...
has_many :personal_messages, dependent: :destroy
# ...
Отлично, все ассоциации сейчас установлены. Давайте перейдем к контроллерам, представлениям и маршрутам.
Отображение разговоров
Прежде всего, добавьте глобальное before_action
application_controller.rb
# ...
before_action :authenticate_user!
# ...
На главной странице нашего приложения я хочу перечислить все разговоры, в которых участвует текущий пользователь. Проблема, однако, в том, что «участвует» означает, что они являются либо автором, либо получателем:
conversations_controller.rb
class ConversationsController < ApplicationController
def index
@conversations = Conversation.participating(current_user).order('updated_at DESC')
end
end
Чтобы сделать эту работу, введите новую область под названием participating
модели / conversation.rb
# ...
scope :participating, -> (user) do
where("(conversations.author_id = ? OR conversations.receiver_id = ?)", user.id, user.id)
end
# ...
Ницца. Добавьте корневой маршрут:
конфиг / routes.rb
# ...
root 'conversations#index'
# ...
Создать вид
просмотров / разговоры / index.html.erb
<h1>Your conversations</h1>
<div id="conversations">
<%= render @conversations %>
</div>
Добавьте партиал для рендеринга разговора:
просмотров / разговоры / _conversation.html.erb
<div>
Chatting with <%= conversation.with(current_user).name %>
<br>
<em><%= conversation.personal_messages.last.body.truncate(50) %></em>
<br>
<%= link_to 'View conversation', conversation_path(conversation) %>
<hr>
</div>
conversation_path
with
модели / conversation.rb
# ...
def with(current_user)
author == current_user ? receiver : author
end
# ...
Добавьте действие show
ConversationsController
Однако перед вызовом этого действия мы должны убедиться, что пользователь действительно авторизован для просмотра запрошенного диалога:
conversations_controller.rb
# ...
before_action :set_conversation, except: [:index]
before_action :check_participating!, except: [:index]
def show
@personal_message = PersonalMessage.new
end
private
def set_conversation
@conversation = Conversation.find_by(id: params[:id])
end
def check_participating!
redirect_to root_path unless @conversation && @conversation.participates?(current_user)
end
# ...
Внутри действия show
@personal_message
participates?
это еще один метод экземпляра:
модели / conversation.rb
# ...
def participates?(user)
author == user || receiver == user
end
# ...
Теперь вот вид:
просмотров / разговоры / show.html.erb
<h1>Chatting with <%= @conversation.with(current_user).name %></h1>
<div id="conversation-main">
<div id="conversation-body">
<%= render @conversation.personal_messages %>
</div>
<%= form_for @personal_message do |f| %>
<%= hidden_field_tag 'conversation_id', @conversation.id %>
<%= f.label :body %>
<%= f.text_area :body %>
<%= f.submit %>
<% end %>
</div>
Эта точка зрения на самом деле довольно проста. Сначала создайте список уже существующих сообщений, а затем предоставьте форму для отправки нового сообщения.
Создайте частичное для отображения фактического сообщения:
просмотров / personal_messages / _personal_message.html.erb
<p><%= personal_message.body %></p>
<p>at <strong><%= personal_message.created_at %></strong><br>
by <strong><%= personal_message.user.name %></strong></p>
<hr>
Отвечая на разговоры
Теперь, конечно, нам нужен новый контроллер для обработки личных сообщений, поэтому создайте его:
personal_messages_controller.rb
class PersonalMessagesController < ApplicationController
before_action :find_conversation!
def create
@personal_message = current_user.personal_messages.build(personal_message_params)
@personal_message.conversation_id = @conversation.id
@personal_message.save!
flash[:success] = "Your message was sent!"
redirect_to conversation_path(@conversation)
end
private
def personal_message_params
params.require(:personal_message).permit(:body)
end
def find_conversation!
@conversation = Conversation.find_by(id: params[:conversation_id])
redirect_to(root_path) and return unless @conversation && @conversation.participates?(current_user)
end
end
Внутри before_action
Затем, если он найден и пользователь участвует в нем, создайте новое личное сообщение и сохраните его. Наконец, перенаправьте обратно на страницу разговора.
Далее добавьте маршруты:
конфиг / routes.rb
# ...
resources :personal_messages, only: [:create]
resources :conversations, only: [:index, :show]
# ...
Начало нового разговора
В настоящее время нет возможности начать новый разговор с пользователем, поэтому давайте исправим это сейчас. Создайте контроллер для управления пользователями:
users_controller.rb
class UsersController < ApplicationController
def index
@users = User.all
end
end
index
просмотров / пользователей / index.html.erb
<h1>Users</h1>
<ul><%= render @users %></ul>
и частичное:
просмотров / пользователей / _user.html.erb
<li>
<%= user.name %> | <%= link_to 'send a message', new_personal_message_path(receiver_id: user) %>
</li>
Настроить маршруты:
конфиг / routes.rb
# ...
resources :users, only: [:index]
resources :personal_messages, only: [:new, :create]
# ...
Также представьте новую ссылку в меню:
общий / _menu.html.erb
# ...
<ul>
<li><%= link_to 'Home', root_path %></li>
<li><%= link_to 'Users', users_path %></li>
</ul>
Теперь добавьте new
PersonalMessagesController
personal_messages_controller.rb
# ...
def new
@personal_message = current_user.personal_messages.build
end
# ...
Однако есть проблема. Когда find_conversation!
метод вызывается как часть before_action
@conversation = Conversation.find_by(id: params[:conversation_id])
:conversation_id
Поэтому нам нужно ввести немного более сложную логику:
- Если задано
:receiver_id
- Если пользователь не был найден, перенаправьте на корневой путь (конечно, вы можете отобразить какую-то ошибку).
- Если пользователь был найден, проверьте, существует ли уже разговор между ним и текущим пользователем.
- Если диалог существует, перенаправьте на действие «Показать разговор».
- Если он не существует, создайте форму для начала разговора.
- И наконец, если идентификатор
:receiver_id
:conversation_id
Вот обновленная find_conversation!
Метод и new
conversations_controller.rb
# ...
def new
redirect_to conversation_path(@conversation) and return if @conversation
@personal_message = current_user.personal_messages.build
end
private
def find_conversation!
if params[:receiver_id]
@receiver = User.find_by(id: params[:receiver_id])
redirect_to(root_path) and return unless @receiver
@conversation = Conversation.between(current_user.id, @receiver.id)[0]
else
@conversation = Conversation.find_by(id: params[:conversation_id])
redirect_to(root_path) and return unless @conversation && @conversation.participates?(current_user)
end
end
# ...
between
модели / conversation.rb
# ...
scope :between, -> (sender_id, receiver_id) do
where(author_id: sender_id, receiver_id: receiver_id).or(where(author_id: receiver_id, receiver_id: sender_id)).limit(1)
end
# ...
Вот мнение:
просмотров / personal_messages / new.html.erb
<h1>New message to <%= @receiver.name %></h1>
<%= form_for @personal_message do |f| %>
<%= hidden_field_tag 'receiver_id', @receiver.id %>
<%= f.label :body %>
<%= f.text_area :body %>
<%= f.submit %>
<% end %>
Действие create
В настоящее время мы не учитываем, что разговор может не существовать, поэтому исправим это сейчас:
personal_messages_controller.rb
# ...
def create
@conversation ||= Conversation.create(author_id: current_user.id,
receiver_id: @receiver.id)
@personal_message = current_user.personal_messages.build(personal_message_params)
@personal_message.conversation_id = @conversation.id
@personal_message.save!
flash[:success] = "Your message was sent!"
redirect_to conversation_path(@conversation)
end
# ...
Это оно! Наша система обмена сообщениями готова, и вы можете увидеть ее в действии!
Немного стиля
Пока мы показываем самые новые сообщения внизу, давайте немного стилизовать страницу беседы, чтобы сделать ее более удобной для пользователя:
application.scss
#conversation-body {
max-height: 400px;
overflow-y: auto;
margin-bottom: 2em;
}
В большинстве случаев пользователь интересуется последними сообщениями, поэтому прокруткой до нижней части окна сообщений также является хорошей идеей:
JavaScripts / conversations.coffee
jQuery(document).on 'turbolinks:load', ->
messages = $('#conversation-body')
if messages.length > 0
messages_to_bottom = -> messages.scrollTop(messages.prop("scrollHeight"))
messages_to_bottom()
Мы в основном определяем функцию и вызываем ее, как только страница загружается. Если вы не используете Turbolinks, первая строка должна быть
jQuery ->
Наконец, требуется этот файл CoffeeScript:
JavaScripts / application.js
//= require conversations
Добавление поддержки Emoji
Смайлики делают разговор в Интернете немного более красочным (ну, если кто-то не злоупотребляет ими). Поэтому, почему бы нам не добавить поддержку Emoji в наше приложение? Это легко с жемчужиной эмодзи . Поместите его в Gemfile :
Gemfile
# ...
gem 'emoji'
# ...
и установить, запустив:
$ bundle install
Добавьте новый вспомогательный метод, найденный здесь :
application_helper.rb
# ...
def emojify(content)
h(content).to_str.gsub(/:([\w+-]+):/) do |match|
if emoji = Emoji.find_by_alias($1)
%(<img alt="#$1" src="#{image_path("emoji/#{emoji.image_filename}")}" style="vertical-align:middle" width="20" height="20" />)
else
match
end
end.html_safe if content.present?
end
# ...
Этот метод может использоваться в любом представлении или частично:
просмотров / personal_messages / _personal_message.html.erb
<p><%= emojify personal_message.body %></p>
<p>at <strong><%= personal_message.created_at %></strong><br>
by <strong><%= personal_message.user.name %></strong></p>
<hr>
Вы также можете представить ссылку на шпаргалку Emoji где-нибудь в вашем приложении.
Вывод
Хорошо, первая версия нашего приложения для обмена сообщениями готова и работает довольно хорошо. В следующей части мы сделаем его более современным, используя веб-сокеты на базе ActionCable и внедрив функцию «пользователь онлайн». Между тем, если у вас есть какие-либо вопросы, не стесняйтесь обращаться ко мне.
Я благодарю вас за то, что вы остались со мной и до скорой встречи!