26 Mayıs 2020 Salı

ORTA SEVİYE BİR RAİLS TUTORIAL - 7

Önceki bölümü okumadan buraya geldiyseniz hiç kasmayın geri dönün




Anlık mesajlaşma


Kullanıcılar gönderi yayınlayabiliyor veya diğer kullanıcıların gönderilerini okuyabiliyor. Fakat diğerleri ile iletişim kurma imkanları yok. Kolay ve hızlı olsun diye basit bir mail kutusu kurabiliriz. Ama bu birileriyle iletişim kurmanın çok eski bir yolu. Anlık mesajlaşma hem kullanım olarak rahat hem de geliştirme için daha heyecan verici.

Neyseki Rails gerçek zaman özelliklerini kolayca uygulayabilen Action Cables‘a sahip. Ana prensibi HTTP protokolü yerine WebSockets protokolü kullanması. WebSockets’in ana konsepti bir client-server bağlantısı kurar ve onu açık tutar. Bunun anlamı ilave veri alışverişi yaparken sayfa yeniden yüklenmeyecektir.



Özel mesajlaşma


Bu kısımın amacı 2 kullanıcı arasında özel görüşme ve mesajlaşma imkanı sunmak.



Modellerin belirlenmesi


Gereken modelleri tanımlayarak başlayalım. İki farklı modele ihtiyacımız var. Biri özel görüşmeler diğeri özel mesajlar için. Şöyleki bir kişi ile mesajlaşmaya başladığınızda onunla bir görüşme başlatırsınız. Görüşme esnasında birçok mesaj alınır verilir. Mesajlar görüşmelere aittir. Bu modellere sırasıyla PrivateConversation ve PrivateMessage diyebiliriz. Fakat bu isimlendirme bize kısa zamanda karışıklıklar olarak geri dönebilir. Her şey yolunda gitse bile models klasörünün nasıl görüneceğini hayal edelim. Aynı ilk kelimeye sahip 2 model oluşturduk. Klasörün içi zor yönetilebilir hale gelebilir.

Klasörler içinde bu kaotik yapılanmadan korunmak için biraz düzenleme yapmalıyız.



Nasıl göründüğüne bakalım. Normal yapıda olunca görüşmelerin modeli  PrivateConversation  adını alacak ve dosyası da  private_conversation.rb  olacaktır.

models/private_conversation.rb

Bunun yerine namespace kullanarak isimlendirme yaparsak model adımız Private::Conversation olacak ve private klasörü altında conversation.rb adında bir dosyada bulunacaktır.

models/private/conversation.rb
Bu daha kullanışlı olacaktır. Tüm private ilk kelimesiyle başlayan modelleri models klasörü altında birbirinden ayırmaya çalışacağımıza private klasörü altında toplamış oluruz.

Genellikle Rails geliştirme prosesini kolaylaştırır. Bu şekilde namespace olarak ifade edilen modelleri üretmek istediğimizde modeli içine koyacağımız klasörü belirtmemiz yeterli olacaktır.

Private::Conversation modelini oluşturmak için konsolda şu komutu girelim:

rails g model private/conversation

ayrıca Private::Message modelini de üretelim:

rails g model private/message

Eğer models klasörüne bakarsak private.rb adında bir dosya görürüz. Bu veritabanı tablolarına isim verirken önlerine private_ ilk kelimesi getirmek için kullanılır. Bu dosyayı burada tutacağımıza her modelin kendi doyasında tablo adını tam olarak girelim ve bu dosyaya gerek kalmasın, kafa karıştırmasın.

Bir modelin içinde tablo adını belirtmek için self.table_name= yönergesini kullanırız. Şimdi model dosyalarını şöyle değiştirelim:

app/models/private/conversation.rb
class Private::Conversation < ApplicationRecord
    self.table_name = "private_conversations"
end

app/models/private/message.rb
class Private::Message < ApplicationRecord
    self.table_name = "private_messages"
end

Artık models klasöründeki private.rb dosyasına ihtiyacımız yok, onu da silelim gitsin.

Bir kullanıcı bir çok görüşmeye sahip olabilir ve görüşmeler de bir çok mesaja sahiptirler. Bu ilişkileri modellerin içinde tanımlayalım:

app/models/private/conversation.rb
...
    has_many :messages,
            class_name: "Private::Message",
            foreign_key: :conversation_id
    belongs_to :sender, foreign_key: :sender_id, class_name: "User"
    belongs_to :recipient, foreign_key: :recipient_id, class_name: "User"
...

app/models/private/message.rb
...
    belongs_to :user
    belongs_to :conversation, 
            foreign_key: :conversation_id, 
            class_name: "Private::Conversation"
...


Burada class_name metodu kullanarak bağlı modelin adını net belirttik. Namespace ile ifade ettiğimiz modellere ulaşılmasını bu şekilde model adını ayrıca belirterek sağlıyoruz. Aynı şekilde User modeline bağlı recipient ve sender sütunları da özel olarak class_name metodu ile hangi tabloya bağlı olduklarını belirtmemize gerek vardır.

foreign_key tablolar arasındaki bağlantı yapılırken diğer tablodaki id sütunu ile eşleşecek olan sütunun adıdır. Veritabanı kullananlar bilir, bu sütun belongs_to tarafındaki model içinde tanımlanır ve karşı gelen tabloda bu sütundaki değere sahip id değeri olan kayıt ya da kayıtlar eşleştirilir.

Görüşmeler iki kullanıcı arasında olacaktır. Bu iki kullanıcı mesajı gönderen sender ve mesajı alan recipient sütunları ile ifade edilir. Bunlara user1 ve user2 isimleri de verebilirdik. Fakat bu şekil isimlendirerek görüşmeyi başlatanın kim olduğunu da biliyoruz, sender sütunundaki kullanıcı görüşmeyi başlatan kişidir.

Şimdi migrasyonlar içinde tablo tanımlamalarını yapalım:

db/migrate/2020xxxxxxxxx_create_private_conversations.rb
class CreatePrivateConversations < ActiveRecord::Migration[5.1]
  def change
    create_table :private_conversations do |t|
        t.integer :recipient_id
        t.integer :sender_id

      t.timestamps
    end

    add_index :private_conversations, :recipient_id
    add_index :private_conversations, :sender_id
    add_index :private_conversations, [:recipient_id, :sender_id], unique: true
  end
end

db/migrate/2020xxxxxxxxx_create_private_messages.rb
class CreatePrivateMessages < ActiveRecord::Migration[5.1]
  def change
    create_table :private_messages do |t|
        t.text :body
        t.references :user, foreign_key: true
        t.belongs_to :conversation, index: true
        t.boolean :seen, default: false

      t.timestamps
    end
  end
end

body sütununda mesajın içeriği bulunacak. İlişkileri tanımlamak ve referans eden id sütunlarını tanımlamak yerine burada references metodunu kullandık. Bu satır user_id adında bir sütun ekler ve bununla User tablosunun id sütununa bağlanır, ve tabloya foreign_key olarak bildirilir. belongs_to metodu da aslında references metodunun aynısı, sadece anlaşılabilirlik açısından hangisini istersek onu kullanırız. Bu satırda da conversation_id adında bir satır eklenerek Conversation tablosunun id sütununa bağlanıyor ve bu sütun tablonun bir index‘i olarak belirtiliyor. En son eklenen seen sütunu da tahmin edersiniz mesajın karşı tarafından görüldüğünü bildirecek.

Migrasyonları kaydettikten sonra çalıştıralım ve tablolar veritabanımıza eklensin:

rails db:migrate



Gerçek zamanlı olmayan bir mesaj penceresi


Özel görüşme verilerini saklayacak bir yerimiz oldu, fakat daha çok iş var. Nereden başlayalım? Önce basit kullanıcı arabirimi oluşturalım, sonra işlevselliğini artırmak için lojik ekleyelim. Eğer arabirimimiz olursa problemleri küçük parçalar halinde tek tek işleye işleye gidebiliriz, bütünü düşünmek çok daha zor olur.

Görüşmeler için arabirimi oluşturmaya Private::Conversations kontrolörü nü oluşturarak başlayalım. Madem namespace kullanmaya başladık tüm uygulamada ona sadık kalalım.

rails g controller private/conversations
Rails jeneratörler çok yeteneklidir. Aynı modellerde olduğu gibi tüm görselleri , herşeyi namespace’e uygun olarak oluşturur. Zaten bu yetenekler daha sonra bir çok framework tarafından esin kaynağı olarak kullanılmıştır.



Yeni görüşme başlatmak


Yeni bir görüşmeyi başlatmak için bir yönteme ihtiyacımız var. Bizim uygulamamızda kendimizle benzer ilgi alanları olan kişilerle görüşme gerçekleştirmek istiyoruz. Buna başlamak için en uygun yerlerden biri kullanıcının bir gönderisinin yayınlandığı sayfa.

Bu sayfanın görsel kalıbı olan posts/show.html.erb içine bunu eklemek için bir form ekleyelim. Formun amacı yeni bir görüşmeyi başlatmak. <p><%= @post.content %></p> satırının altına şunu ekleyelim:

app/views/posts/show.html.erb
...
            <%= render contact_user_partial_path %>
...

Bu yardımcı metodu posts_helper.rb dosyası içinde tanımlayalım.

app/helpers/posts.helper.rb
...
    def contact_user_partial_path
        if user_signed_in?
            @post.user.id != current_user.id ? "posts/show/contact_user" : "shared/empty_partial"
        else
            "posts/show/login_required"
        end
    end
...

show klasörü ve içinde gereken parça görsel kalıplarını oluşturalım.

app/views/posts/show/_contact_user.html.erb
<div class="contact-user">
    <%= render leave_message_partial_path %>
</div>

app/views/posts/show/_login_required.html.erb
<div class="text-center">
    Kullanıcı ile iletişim kurmak için <%= link_to "Login", login_path %> olmanız gerekir
</div>

posts_helper.rb içinde leave_message_partial_path metodunu tanımlayalım. Bu ekstra metod tanımlamalar falan sırf kodumuzu daha okunabilir ve kolay müdahale edilebilir yapmak için.

app/helpers/posts.helper.rb
...
    def leave_message_partial_path
        if @message_has_been_sent
            "posts/show/contact_user/already_in_touch"
        else
            "posts/show/contact_user/message_form"
        end
    end
...

Burada @message_has_been_sent değişkeni bize daha önce mesaj gönderilmiş olduğu bilgisini verecek. Mesaj gönderilmişse already_in_touch.html.erb dosyasını , gönderilmemişse message_form.html.erb dosyasını parça görsel olarak yayınlayacak.

Bu @message_has_been_sent oluşum değişkenini PostsController içinde tanımlayacağız.

leave_message_partial_path metodu içinde gerek duyulan iki parça görselini contact_user alt klasörü içinde tanımlayalım.

app/views/posts/show/contact_user/_already_in_touch.html.erb
<div class="contacted-user">
    Bu kullanıcı ile halihazırda temas kurdunuz
</div>

app/views/posts/show/contact_user/_message_form.html.erb
<%= form_tag({controller: "private/conversations", action: "create"},
        method: "post",
        remote: true) do %>
    <%= hidden_field_tag(:post_id, @post.id) %>
    <%= text_area_tag(:message_body,
                    nil,
                    rows: 3,
                    class: "form-control",
                    placeholder: "Kullanıcıya bir mesaj gönder") %>
    <%= submit_tag("Mesaj gönder", class: "btn send-message-to-user") %>
<% end %>

Şimdi PostsController‘in show aksiyonuna ilave yapalım:

app/controllers/posts.controller.rb
...
    def show
        @post = Post.find(params[:id])

        if user_signed_in?
            @message_has_been_sent = conversation_exist?
        end
    end
...

Kontrolörün private bölümüne conversation_exist? metodunu ekleyelim

app/controllers/posts.controller.rb
...
    private

    def conversation_exist?
        Private::Conversation.between_users(current_user.id, @post.user.id).present?
    end
...

between_users metodu iki kullanıcı arasında olan görüşmeleri sorguluyor. Bu metodu Private::Conversation modeli içinde bir scope olarak tanımlayalım.

app/models/private/conversation.rb
...
    scope :between_users, -> (user1_id, user2_id) do
        where(sender_id: user1_id, recipient_id: user2_id).or(
            where(sender_id: user2_id, recipient_id: user1_id))
    end
...

Sırada Private::Conversatios kontrolörü içine create aksiyonu tanımlamak var.

app/controllers/private/conversations_controller.rb
...
    def create
        recipient_id = Post.find(params[:post_id]).user.id
        conversation = Private::Conversation.new(sender_id: current_user.id,
                                                recipient_id: recipient_id)
        if conversation.save
            Private::Message.create(user_id: recipient_id,
                                    conversation_id: conversation.id,
                                    body: params[:message_body])
            respond_to do |format|
                format.js {render partial: "posts/show/contact_user/message_form/success"}
            end
        else
            respond_to do |format|
                format.js {render partial: "posts/show/contact_user/message_form/fail"}
            end
        end
    end
...

Burada mevcut kullanıcı ile gönderi yazarı arasında yeni bir görüşme başlatıyoruz. Eğer herşey yolunda giderse mevcut kullanıcı tarafından yazılmış bir mesaj oluşturulacak ve bu başarı bir mesaj ile kullanıcıya bildirilecek. Bildirimlerde JavaScript parça dosyalar kullanıyoruz. Bu parça dosyaları oluşturalım:

app/views/posts/show/contact_user/message_form/_success.js.erb
$(".contact-user").replaceWith('\
    <div class="contact-user">\
        <div class="contacted-user">Mesaj gönderildi</div>\
    </div>');

app/views/posts/show/contact_user/message_form/_fail.js.erb
$(".contact-user").replaceWith('<div>Mesaj gönderilemedi</div>');

Route’lar eksik kaldı. Private::Conversations ve Private::Messages kontrolörleri için route’ları tanımlayalım:

config/routes.rb
  namespace :private do
    resources :conversations, only: [:create] do
      member do
        post :close
      end
    end
    resources :messages, only: [:index, :create]
  end

Sadece birkaç aksiyona ihtiyacımız var, bu durumlarda only metodu çok faydalıdır. namespace metodu da buradaki gibi namespace ile oluşturduğumuz kontrolörler için route tanımlamalarında kullanılır.

Mesajlaşma için koyduğumuz form hazır gibi. Biraz da stili ile uğraşalım. branch_page.scss dosyasına ilave yapalım.

app/assets/stylesheets/partials/posts/branch_page.scss
...
.send-message-to-user {
    background-color: $navbarColor;
    padding: 10px;
    color: white;
    border-radius: 10px;
    margin-top: 10px;
    &:hover {
        background-color: black;
        color: white;
    }
}

.contact-user {
    text-align: center;
}

.contacted-user {
    display: inline-block;
    border-radius: 10px;
    padding: 10px;
    background-color: $navbarColor;
    color: white;
}
...

Şimdi tek gönderi sayfasını açtığımızda şöyle bir görüntü olacaktır.




Bir mesaj yazıp gönderdiğimizde ise şöyle bir görüntü olacaktır.




Daha önce temas kurduğumuz bir kullanıcının gönderisine baktığımızda da şöyle bir görüntü olacaktır:






Bir görüşme penceresi yayınlamak


Bir mesaj göndererek yeni bir görüşmeyi başlattık. Şu andaki yegane kabiliyetimiz bu, başka bir şey yapamıyoruz. Hatta mesajın karşı tarafından okunduğunu bile görmüyoruz. Bu kabiliyet bu kadarıyla işimize yaramaz mesajları okumak ve yazmak için bir mesajlaşma penceresine ihtiyacımız var.

Açılmış olan görüşmeleri oturum bilgisi içinde saklayacağız. Böylece kullanıcı görüşmeyi sonlandırana ya da oturumu bitirene kadar görüşmeler açık kalacak.

Private::ConversationsController‘ın create aksiyonu içinde görüşmenin kaydedilmesini sağlayan add_to_conversations metodu çağrısı ekleyelim. Sonra da metodu private bölümünde tanımlayalım.

app/controllers/private/conversations_controller.rb
    def create
    ...
        else
            respond_to do |format|
                format.js {render partial: "posts/show/contact_user/message_form/fail"}
            end
        end

        add_to_conversations unless already_added?
            
    end

    private

    def add_to_conversations
        session[:private_conversations] ||= []
        session[:private_conversations] << @conversation.id
    end

Bu görüşme id’sini oturum bilgileri içine ekleyecektir. already_added? metodu ise görüşmenin id’sinin şimdiye kadar oturum bilgilerine eklenmediğinden emin olmak içindir. Bunu da kontrolörün private bölümüne ekleyelim.

app/controllers/private/conversations_controller.rb
...
    def already_added?
        session[:private_conversations].include?(@conversation.id)
    end
...

Bir fark daha, burada conversation değişkenini daha sonra görsellerde kullanabilmek amacıyla oluşum değişkenine çevirdik (başına @ karakteri ekledik). Bunu da tüm create metodu içinde kullanıldığı yerlere uygulayalım.

Şimdi görüşme penceresi için görsel kalıp hazırlamaya başlayabiliriz. Pencere için bir parça görsel oluşturalım.

app/views/private/conversations/_conversation.html.erb
<% @recipient = private_conv_recipient(conversation) %>
<% @is_messenger = false %>
<li class="conversation-window" id="pc<%= conversation.id %>"
    data-pconversation-user-name="<%= @recipient.name %>" 
    data-turbolinks-permanent>
    <div class="panel panel-default" data-pconversation-id="<%= conversation.id %>">
        <%= render "private/conversations/conversation/heading",
                    conversation: conversation %>

        <!-- Görüşme penceresi içeriği -->
        <div class="panel-body">
            <%= render "private/conversations/conversation/messages_list",
                        conversation: conversation %>
            <%= render "private/conversations/conversation/new_message_form",
                        conversation: conversation,
                        user: user %>
        </div><!-- panel-body -->
    </div>
</li><!-- conversation-window -->

Burada private_conv_recipient metodu ile görüşmenin alıcısını bulduk. Yardımcı metodlar içinde bu metodu tanımlayalım.

app/helpers/private/conversations_helper.rb
module Private::ConversationsHelper

    def private_conv_recipient(conversation)
        conversation.opposed_user(current_user)
    end
    
end

opposed_user metodu kullanılıyor. Bunu tanımlamak için Private::Conversation modeline ekleyelim.

app/models/private/conversation.rb
    def opposed_user(user)
        user == recipient ? sender : recipient
    end

Bu metod bir görüşmede karşı kişinin id’sini buluyor.

Tekrar _conversation.html.erb dosyasına dönelim ve gerekli olan parça görselleri hazırlayalım.

app/views/private/conversations/conversation/_heading.html.erb
<div class="panel-heading conversation-heading">
    <span class="contact-name-notif"><%= @recipient.name %></span>
</div>

<!-- görüşmeyi bitir butonu -->
<%= link_to "X",
            close_private_conversation_path(conversation),
            class: "close-conversation",
            title: "Close",
            remote: true,
            method: :post %>

app/views/private/conversations/conversation/_messages_list.html.erb
<div class="messages-list">
    <%= render load_private_messages(conversation), conversation: conversation %>
    <div class="loading-more-messages">
        <i class="fa fa-spinner" aria-hidden="true"></i>
    </div>

    <!-- mesajlar -->
    <ul>
        
    </ul>
</div>

Private::ConversationsHelper yardımcı dosyası içinde load_private_messages metodunu tanımlayalım.

app/helpers/private/conversations_helper.rb
...
    # eğer görüşmede gösterilmeyen mesajlar varsa yüklemek için buton ekle
    def load_private_messages(conversation)
        if conversation.messages.count > 0
            "private/conversations/conversation/messages_list/link_to_previous_messages"
        else
            "shared/empty_partial"
        end
    end
...

Bu önceki mesajları göstermek için bir link ekleyecektir. Karşı gelen parça dosyasını yeni ekleyeceğimiz messages_list klasörü içine ekleyelim.

app/views/private/conversations/conversation/messages_list/_link_to_previous_messages.html.erb
<%= link_to "Mesajları yükle",
            private_messages_path(:conversation_id => conversation.id,
                                :messages_to_display_offset => @messages_to_display_offset,
                                :is_messenger => @is_messenger),
            class: "load-more-messages",
            remote: true %>

Görüşme penceresi tüm uygulama genelinde erişilebilir olacaktır. Private::ConversatiosHelper içindeki yardımcı dosyaların tüm uygulama genelinde erişilebilir olması için ApplicationHelper içine şu ilaveyi yapıyoruz.

app/helpers/application_helper.rb
module ApplicationHelper
    include NavigationHelper
    include PostsHelper
    include Private::ConversationsHelper
end

Sonra yeni mesaj formu için son parça dosyayı da tanımlayalım.

app/views/private/conversations/conversation/_new_message_form.html.erb
<form class="send-private-message">
    <input type="hidden" name="conversation_id" value="<%= conversation.id %>">
    <input type="hidden" name="user_id" value="<%= user.id %>">
    <textarea name="body" rows="3" class="form-control" placeholder="Bir mesaj yazın"></textarea>
    <input type="submit" class="btn btn-success send-message">
</form>

Bu formu ileride daha işlevsel yapacağız.

Şimdi kullanıcı gönderi izleme ekranından bir mesaj gönderdiğinde görüşme penceresini uygulamada yayınlanmasını sağlayalım

_success.js.erb dosyası içine şu ilaveyi yapalım:

app/views/posts/show/contact_user/message_form/_success.js.erb
$(".contact-user").replaceWith('\
    <div class="contact-user">\
        <div class="contacted-user">Mesaj gönderildi</div>\
    </div>');
<%= render "private/conversations/open" %>

Bu parça dosyanın amacı uygulamaya görüşme penceresini eklemek. Parça dosyayı tanımlayalım.

app/views/private/conversations/_open.js.erb
var conversation = $("body").find("[data-pconversation-id='" + 
                                "<%= @conversation.id %>" + "']");
var chat_windows_count = $(".conversation-window").length + 1;

if (conversation.length !== 1) {
    $("body").append("<%= j(render 'private/conversations/conversation',\
                                conversation: @conversation,\
                                user: current_user) %>");

    conversation = $("body").find("[data-pconversation-id='" + 
                                "<%= @conversation.id %>" + "']");
}

// pencereler açılır-küçülür olacak 
// yeni pencereyi açmak için heading tıklamasını aktif et
// üretildikten sonra otomatik açılsın
$(".conversation-window:nth-of-type(" + chat_windows_count + ")\
    .conversation-heading").click();

// pencereyi tıklayarak mesajı okundu olarak işaretle
// bu da sonra lazım olacak
setTimeout(function(){
    $(".conversation-window:nth-of-type(" + chat_windows_count + ")").click();
}, 1000);

// yazı kısmına odaklan
$("conversation-window:nth-of-type(" + chat_windows_count + ")\
    form\
    textarea").focus();

// tüm görüşme pencerelerini pozisyonla
positionChatWindows();

Bu parça görev dosyası bir çok senaryoda kullanılacak. Aynı pencereyi birçok defa açmayı engellemek için, pencereyi yayınlamadan evvel uygulamada halihazırda aktif olup olmadığı kontrol edilmeli. Sonra pencere genişletilip yazı alanına odaklanmalı. Dosyanın en sonunda positionChatWindows() fonksiyonu çağırılarak tüm pencerelerin düzgün yerleştirilmesi sağlanıyor. Eğer bunu yapmazsak tüm pencereler aynı yerde yayınlanır ve bu kullanışsız olur.

Şimdi assets klasörü altında görüşme pencerelerinin görülebilirliğini ve pozisyonlarını kontrol eden bir dosya ekleyelim.

app/assets/javascripts/conversations/position_and_visibility.js
// Sayfa yüklemesinden sonra
$(document).on("turbolinks:load", function(){
    chat_windows_count = $(".conversation-window").length;

    // eğer son görülebilir chat penceresi ayarlanmadıysa ve görüşme penceresi mevcutsa
    // son görülebilir chat penceresi değişkenini ayarla
    if (gon.last_visible_chat_window == null && chat_windows_count > 0) {
        gon.last_visible_chat_window = chat_windows_count;
    }

    // eğer gon.hidden_chats değişkeni yoksa tanımla
    if (gon.hidden_chats == null) {
        gon.hidden_chats = 0;
    }

    window.addEventListener("resize", hideShowChatWindow);
    positionChatWindows();
    hideShowChatWindow();
});

function positionChatWindows() {
    chat_windows_count = $(".conversation-window").length;
    // eğer yeni görüşme penceresi eklenmişse,
    // onu son görülebilir chat penceresi olarak işaretle
    // böylece hideShowChatWindow fonksiyonu onu 
    // ekran genişliğine göre gösterip gizleyebilir
    if (gon.hidden_chats + gon.last_visible_chat_window !== chat_windows_count) {
        if (gon.hidden_chats == 0) {
            gon.last_visible_chat_window = chat_windows_count;
        }
    }

    // yeni chat penceresi eklendiğinde onu listenin en sol başına yerleştir
    for(i=0; i<chat_windows_count; i++) {
        var right_position = i * 410;
        var chat_window = i + 1;
        $(".conversation-window:nth-of-type(" + chat_window + ")")
            .css("right", "" + right_position + "px"); 
    }
}

// Eğer son görüşme penceresi ekranın sol kenarına yakınsa onu gizler
function hideShowChatWindow() {
    // eğer görüşme penceresi yoksa fonksiyonu durdur
    if ($(".conversation-window").length < 1) {
        return;
    }

    // en soldaki pencerenin koordinatlarını al
    var offset = $(".conversation-window:nth-of-type(" + gon.last_visible_chat_window + ")").offset();
    
    // görüşme penceresi left koordinatı 50 den küçükse onu gizle
    if (offset.left < 50 && gon.last_visible_chat_window !== 1) {
        $(".conversation-window:nth-of-type(" + gon.last_visible_chat_window + ")")
            .css("display", "none");
        gon.hidden_chats++;
        gon.last_visible_chat_window--;
    }

    // eğer en soldaki pencere koordinatı 550 den büyükse
    // ve gizlenmiş görüşme varsa onu göster
    if (offset.left > 550 && gon.hidden_chats !== 0) {
        gon.hidden_chats--;
        gon.last_visible_chat_window++;
        $(".conversation-window:nth-of-type(" + gon.last_visible_chat_window + ")")
            .css("display", "initial");
    }
}


JavaScript ile arada veri alışverişi için cookie ya da benzer şeylere ait kendi fonksiyonlarımızı yazmaya çalışmak yerine gon gem’i kullanabiliriz. Bu gem’in orjinal kullanımı veriyi server tarafından JavaScript tarafına göndermek üzerine. Fakat JavaScript değişkenlerini uygulama genelinde erişilebilir yapmak için de çok faydalıdır. Bu gem’i uygulamamıza ekleyelim.

gem 'gon'
gem 'rabl-rails'

satırlarını Gemfile dosyasına ekledikten sonra

bundle

ile gem’i uygulamamıza dahil ederiz.

Bu gem biraz uyuz sayfalarımızda kullanılabilir olması için application.html.erb genel görsel kalıp dosyamızda <head> kısmına şu satırı eklememiz gerekiyor.

app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  <head>
    <title>Yeni</title>
    <%= csrf_meta_tags %>

    <%= include_gon(:init => true) %>
...

Ekran genişliğini izleyen bir olay izleyicimiz var. Bir görüşme pencere genişliğinin sol tarafına yaklaşınca, görüşme gizleniyor. Yeterince boş alan oluştuğunda da otomatik olarak görünebilir oluyor. Bir sayfayı ziyaret ettiğimizde yukarıdaki fonksiyonlar çalışarak görüşme pencerelerinin doğru pozisyonda olmasını sağlıyor.

Bootstrap panel elemanını kullanarak görüşme pencerelerini kolayca genişletip daraltabiliyoruz. Default olarak hepsi daraltılmış görüntüde olacak ve etkileşim yapılamayacaktır. Bunları durum değiştirebilir hale getirmek için javascripts klasörü içinde toggle_window.js adında bir dosya tanımlayalım.

app/assets/javascripts/conversations/toggle_window.js
// Sayfa yüklemesinden sonra
$(document).on("turbolinks:load", function(){

    // görüşmenin heading kısmı tıklanınca
    $("body").on("click", 
                ".conversation-heading, conversation-heading-full",
                function(e){
        e.preventDefault();
        var panel = $(this).parent();
        var panel_body = panel.find(".panel-body");
        var messages_list = panel.find(".messages-list");

        panel_body.toggle(100, function(){});
    });
});

Stiller için yeni bir conversation_window.scss dosyası tanımlayalım.

app/assets/stylesheets/partials/conversation_window.scss
textarea {
    resize: none;
}

.panel {
    margin: 0;
    border: none !important;
}

.panel-heading {
    border-radius: 0;
}

.panel-body {
    position: relative;
    display: none;
    padding: 0 0 5px 0;
}

.conversation-window, .new_chat_window {
    min-width: 400px;
    max-width: 400px;
    position: fixed;
    bottom: 0;
    right: 0;
    list-style-type: none;
}

.conversation-heading, .conversation-heading-full, .new_chat_window {
    background-color: $navbarColor !important;
    color: white !important;
    height: 40px;
    border: none !important;
    a {
        color: white !important;
    }
}

.conversation-heading, .conversation-heading-full {
    padding: 0 0 0 15px;
    width: 360px;
    display: inline-block;
    vertical-align: middle;
    line-height: 40px;
}

.close-conversation, .add-people-to-chat, .add-user-to-contacts, .contact-request-sent {
    color: white;
    float: right;
    height: 40px;
    width: 40px;
    font-size: 20px;
    font-size: 2.0rem;
    border: none;
    background-color: $navbarColor;
}

.close-conversation, .add-user-to-contacts {
    text-align: center;
    vertical-align: middle;
    line-height: 40px;
    font-weight: bold;
}

.close-conversation {
    &:hover {
        border: none;
        background-color: white;
        color: $navbarColor !important;
    }
    &:visited, &:focus {
        color: white;
    }
}

.form-control[disabled] {
    background-color: $navbarColor;
}

.send-private-message, .send-group-message {
    textarea {
        border-radius: 0;
        border: none;
        border-top: 1px solid rgba(0, 0, 0, 0.2);
    }
}

.loading_svg {
    display: none;
}

.loading_svg {
    text-align: center;
}

.messages-list {
    z-index: 1;
    min-height: 300px;
    max-height: 300px;
    overflow-y: auto;
    overflow-x: hidden;
    ul {
        padding: 0;
    }
}

.message-received, .message-sent {
    max-width: 300px;
    word-wrap: break-word;
    z-index: 1;
}

.message-sent {
    position: relative;
    background-color: white;
    border: 1px solid rgba(0,0,0, 0.5);
    border-radius: 5px;
    margin: 5px 5px 5px 50px;
    padding: 10px;
    float: right;
}

.message-recived {
    background-color: $backgroundColor;
    border-color: #eeeeee;
    border-radius: 5px;
    margin: 5px 50px 5px 5px;
    padding: 10px;
    float: left;
}

.messages-date {
    width: 100%;
    text-align: center;
    border-bottom: 1px solid rgba(0,0,0, 0.2);
    line-height: 1px;
    line-height: 0.1rem;
    margin: 20px 0 20px;
    span {
        background: #fff;
        padding: 0 10px;
    }
}

.loading-more-messages {
    font-size: 20px;
    font-size: 2.0rem;
    padding: 10px 0;
    text-align: center;
}

.send-message {
    display: none;
}

Gördüğümüz kadarıyla bu tutorialı hazırlayan arkadaşın daha planları çok. Şimdiye kadar yerleştirmediğimiz elemanların da stilleri var burada.

Daha önce yeni başlatılan görüşmenin id’sini oturum bilgisi içinde saklamıştık. Şimdi onu kullanarak görüşme penceresini kullanıcı kapatana kadar ya da oturumum bitirene kadar açık tutacağız. ApplicationController içinde bir filtre tanımlayalım.

app/controllers/application_controller.rb
 before_action :opened_conversations_windows

Sonra da openened_conversations_windows metodunu ekleyelim

app/controllers/application_controller.rb
    def opened_conversations_windows
        if user_signed_in?
            # açık olan görüşmeler
            session[:private_conversations] ||= []
            @private_conversations_windows = Private::Conversation.includes(:recipient, :messages)
                                                .find(session[:private_conversations])
        else
            @private_conversations_windows = []
        end
    end

includes metodu bahsi geçen veritabanı tablosu ile bağlantılı olan diğer tablolardan da bağlantılı verilerin çekileceğini gösterir. Böylece bu sorgunun cevabında sadece görüşmelere ait satırları değil o görüşmenin alıcıları (recipients) ve mesajlarına (messages) ait tüm verileri toplamış oluyoruz. Onlar için bir daha ayrıca sorgu yapmamıza gerek kalmıyor. Bu klasik N + 1 sorgu problemi gibi oluyor. Tek bir sorguda mesajları almazsak ilave yapacağımız sorgula her mesaj için ayrı tetiklenecektir. 100 mesaj için 100 sorgu yapacağımıza bir tek başlangıçta yapılan sorguyla herhangi sayıdaki mesajı alıyoruz. Uygulama ve veritabanı performansı için bunlara dikkat etmek gerekiyor.

Şimdi görsele sıra geldi. Her sayfada çıkması için application.html.erb görsel kalıbına ekleyeceğiz. yield metodu satırı altına şunu ekleyelim:

app/views/layouts/application.html.erb
    <%= render "layouts/application/private_conversations_windows" %>

Parça görsel dosyamız için layouts/application klasörünü ve içine _private_conversations_windows.html.erb dosyasını ekleyelim.

app/views/layouts/application/_private_conversations_windows.html.erb
<% @private_conversations_windows.each do |conversation| %>
    <%= render partial: "private/conversations/conversation",
                locals: { conversation: conversation, 
                            user: current_user } %>
<% end %>

Şimdi yeni mesaj gönderirsek şöyle bir görünümde mesaj pencersini görmeliyiz.



Ancak daha bitmedi. Mesajları görmek için yapılacaklar var.




Görüşmeyi Bitirmek


Görüşmenin “close” butonu halihazırda çalışmıyor. Fakat herşeyimiz hazır. Private::ConversationsController içinde bir close aksiyonu tanımlayalım.

app/controllers/private/conversations_controller.rb
...
    def close
        @conversation_id = params[:id].to_i
        session[:private_conversations].delete(@conversation_id)

        respond_to do |format|
            format.js
        end
    end

    private
...

Kapatma butonu tıklanınca bu aksiyon çağrılır. Aksiyon oturum bilgileri içindeki conversation_id’yi siler ve bir JavaScript dosya ile cevap gönderir. Bu JavaScript dosyanın adı aksiyon ile aynı olacaktır. Bu dosyayı tanımlayalım.

app/views/private/conversations/close.js.erb
$("body")
    .find("[data-pconversation-id='" + "<%= @conversation_id %>" + "']")
    .parent()
    .remove();
positionChatWindows();

Bu script ile ilgili görüşmenin penceresi DOM’dan çıkarılır (yani yok edilir) ve geri kalan görüşme pencereleri yerleşimi tekrar dizayn edilir.




Mesajları Göstermek


Şu anda mesajlar listesinde sadece bir loading ikonu var, ama mesajımız görünmüyor. Sebebi daha mesajlar için görsel kalıp dosyaları oluşturmadık. views/private klasörü altında messages klasörü ekleyelim ve içine bir parça görseli ekleyelim. (Ben anlamadım niye mesajlar için kontrolör üretmiyor da manual yapıyor. Vardır bir bildiği)

app/views/private/messages/_message.html.erb
<%= render private_message_date_check(message, previous_message),
            locals: { message: message } %>
<li title="<%= message.created_at.to_s(:time) %>">
    <div class="row">
        <div class="<%= sent_or_received(message, user) %> <%= seen_or_unseen(message) %>">
            <%= message.body %>
        </div>
    </div>
</li>

private_message_date_check yardımcı metodu mesajın bir önceki mesajla aynı günde yayınlanmasını kontrol ediyor (whatsapp gibi aynı gün mesajları gruplanacak). Eğer farklı günse tarih belirten bir satır ekliyor görüntüye. Bu yardımcı metodu Private::MessagesHelper içinde tanımlayalım.

app/helpers/private/messages_helper.rb
module Private::MessagesHelper

    def private_message_date_check(message, previous_message)
        if defined?(previous_message) && previous_message.present?
            # eğer mesajlar aynı gün içinde yapılmadıysa
            if previous_message.created_at.to_date != message.created_at.to_date
                @message = message
                "private/messages/message/new_date"
            else
                "shared/empty_partial"
            end
        else
            "shared/empty_partial"
        end
    end

end


Tabi bu yardımcı metodun tüm sayfalarda erişilebilir olması için ApplicationHelper içinde Private::MessagesHelper bağlamalıyız.

include Private::MessagesHelper
Yeni message klasörünü ekleyerek _new_date.html.erb parça görselini tanımlayalım.

app/views/private/messages/message/_new_date.html.erb
<div class="messages-date">
    <span><%= @message.created_at.to_date %></span>
</div>

_message.html.erb dosyamıza geri dönersek sent_or_received ve seen_or_unseen adında iki yardımcı metodumuz daha olduğunu görürüz. Bunlar farklı koşullarda farklı sınıflar geri dönerler. Bu metodları da Private::MessagesHelper içinde tanımlayalım.

app/helpers/private/messages_helper.rb
...
    def sent_or_received(message, user)
        user.id == message.user_id ? "message-sent" : "message-received"
    end

    def seen_or_unseen(message)
        message.seen == false ? "unseen" : ""
    end
...

Şimdi mesajları listeye yükleyecek bir komponente ihityacımız var. Ayrıca bu komponent kullanıcı yukarı doğru scroll ettikçe geride mesaj kalmayana kadar daha önceki mesajları da listeye eklemeye devam edecek. Mesajlar için de daha önce gönderiler listesinde yaptığımıza benzer bir “sonsuz scroll” mekanızması kurmalıyız.

views/private/messages klasörü içinde _load_more_messages.js.erb adında bir dosya ekleyelim.

app/views/private/messages/_load_more_messages.js.erb
<% @id_type = "pc" %>
<%= render append_previous_messages_partial_path %>
<%= render replace_link_to_private_messages_partial_path %>

@id_type oluşum değişkeni görüşme için bir tip belirler. Şimdilik sadece bire bir görüşmelerimiz var ama ileride grup görüşmeleri de olacak , bu değişken onun için konmuş. app/helpers klasörü altında shared adında yeni bir klasör ekleyelim ve messages_helper.rb adında yeni bir yardımcı dosya ekleyelim.

app/helpers/shared/messages_helper.rb
module Shared::MessagesHelper

    def append_previous_messages_partial_path
        "shared/load_more_messages/window/append_messages"
    end

end

Metod çok basit görünüyor. Sadece bir parça dosyanın path’ini dönüyor. Bu dosya ileride daha gelişecek ve mesajlaşma sistemimize ekstra özellikler vereceğiz. Şu anda bu yardımcı metodlara hiçbir yerden erişemeyiz. Bu yüzden Private::MessagesHelper içinde bu modüle başvuru yapalım.

app/helpers/private/messages_helper.rb
module Private::MessagesHelper
    require "shared/messages_helper"
    include Shared::MessagesHelper
...

Yukardan bakınca sadece modül adını vermek yetiyordu ama alt klasör içinden bakınca require ile modülü içeren dosyaya bağlanmak gerekti.

Metodda app/views/shared klasörü altında yeni klasörlerden bahsediliyor. Bunları da ekleyelim.

app/views/shared/load_more_messages/window
Sonra da _append_messages.js.erb dosyasını tanımlayalım.

app/views/shared/load_more_messages/window/_append_messages.js.erb
// geçici olarak daha mesaj yükle linkini kaldır
// böylece yeni mesajlar getirilirken tıklanamasın
$("#<%= @id_type %><%= @conversation.id %> .load-more-messages")
    .replaceWith("<span class='load-more-messages'></span>");

// önceki messajları yayınla
$("#<%= @id_type %><%= @conversation.id %> .messages-list ul")
    .prepend('<%= j render "#{@type}/conversations/messages" %>');

// Yeni mesajlar eklendikten sonra scrollbar üzerinde boşluk ekle
$('#<%= @id_type %><%= @conversation.id %> .messages-list').scrollTop(400);

Bu kod önceki mesajları listenin başına ekler, sonra aynı şeyi tekrar eder. Gelelim _load_more_messages.js.erb içindeki diğer metoda. Private::MessagesHelper içine bu metodu ekleyelim.

app/helpers/private/messages_helper.rb
...
    def replace_link_to_private_messages_partial_path
        "private/messages/load_more_messages/window/add_link_to_messages"
    end
...

Karşı gelen yeni klasörleri app/views/private/messages klasörü altına ekleyelim

app/views/private/messages/load_more_messages/window/_add_link_to_messages.js.erb
$("#<%= @id_type %><%= @conversation.id %> .load-more-messages")
    .replaceWith('\
        <%= j render partial: "private/conversations/conversation/messages_list/link_to_previous_messages",\
                    locals: {conversation: @conversation } %>\
    ');

Bu dosya önceki mesajlar için olan linki güncelleyecektir. Önceki mesajlar eklendikten sonra daha önceki mesajları yükleyecek şekilde link yenilenecektir.

Şimdi sistemimiz hazır, önceki mesajlar listenin üst tarafına gelecek. Fakat uygulamaya gidip baktığımızda hiç bir mesajın pencereye gelmediğini görürüz. Neden? Çünkü mesajları yüklemek için olan link hiç bir şeyi tetiklemiyor. Bir görüşme penceresi açtığımızda en son mesajları görmek isteriz. Görüşme penceresini de bu şekilde davranacak şekilde yönlendirebiliriz. Önceki mesajları yükleme periyodunun ilkini başlatırız ve arkası gelir.

toggle_windows.js dosyası içindeki toggle fonksiyonu tam bu işi yapacak. Fonksiyonu düzenleyelim.

app/assets/javascripts/conversations/toggle_window.js
...
        panel_body.toggle(100, function(){
            var messages_visible = $("ul", this).has("li").length;

            // mesaj listesi boşsa ilk 10 mesajı yükle
            if (!messages_visible && $(".load-more-messages", this).length) {
                $(".load-more-messages", this)[0].click();
            }
        });
...

Bir olay izleme kodu yazarak kullanıcı görüşme penceresinin üst kısmına scroll ile yaklaştığında daha fazla mesajı yüklemek için linki tıklatalım. Yeni dosyayı ekleyelim.

app/assets/javascripts/conversations/messages_infinite_scroll.js
$(document).on("turbolinks:load ajax:complete", function() {
    var iScrollPos = 0;
    var isLoading = false;
    var currentLoadingIcon;

    $(document).ajaxComplete(function() {
        isLoading = false;
        // loading ikonunu sakla
        if (currentLoadingIcon !== undefined) {
            currentLoadingIcon.hide();
        }
    });

    $(".messages-list", this).scroll(function() {
        var iCurScrollPos = $(this).scrollTop();

        if (iCurScrollPos > iScrollPos) {
            // aşağı scroll ediyor
        } else {
            // yukarı scroll ediyor
            if (iCurScrollPos < 300 && isLoading == false && $(".load-more-messages", this).length) {
                // 10 mesaj daha yükleyen linki tıkla
                $(".load-more-messages", this)[0].click();
                isLoading = true;

                // loading ikonunu bul ve görünür yap
                currentLoadingIcon = $(".load-more-messages", this);
                currentLoadingIcon.show();
            }
        }
        iScrollPos = iCurScrollPos;
    });
});

Daha fazla mesaj yükleme linki tıklanınca Private::MessagesController ‘ın index aksiyonu çağırılır. Linki tanımlarken böyle tanımlamıştık. _link_to_previous_messages.html.erb dosyasına bakabilirsiniz.

Kontrolörü ve aksiyonunu oluşturalım.

app/controllers/private/messages_controller.rb
class Private::MessagesController < ActionController::Base
    include Messages

    def index
        get_messages("private", 10)
        @user = current_user
        @is_messenger = params["is_messenger"]
        respond_to do |format|
            format.js { render partial: "private/messages/load_more_messages" }
        end
    end
end

Burada Messages modülünden metodları içerdik. Bu modülü controllers/concerns klasörü içine koyacağız. ActiveSupport::Concern daha sonra diğer sınıflar içinde kullanacağımız modülleri koymak için olan yerlerden biri. Yukarıdaki kontrolör kodunda bulunan get_messages metodu bu modülden geliyor. Böyle yapmamızın sebebi aynı metodu daha sonra başka bir kontrolör içinde de kullanacağız.

Burada yazarın bir fikrini size aktarayım, şöyle demiş: Bazı insanların ActiveSupport::Concern ‘den şikayet ettiklerini ve kullanılmaması gerektiğini söylediklerini gördüm. Bu insanları benimle octagonda dövüşmeye davet ediyorum. Şaka yaptım :D, bu bağımsız bir uygulama ve nasıl istersek öyle yaparız. Eğer concern sevmiyorsanız tekrar kullanılan metodlar için başka yollar da var.

Modülü tanımlayalım.

app/controllers/concerns/messages.rb
require "active_support/concern"

module Messages
    extend ActiveSupport::Concern 

    def get_messages(conversation_type, messages_amount)
        # stringi sabite çevirerek fonksiyonun model çağırmasını dinamik hale getirmiş
        # aşağıdan Private::Conversation şeklinde model ismi çıkacak
        model = "#{conversation_type.capitalize}::Conversation".constantize

        @conversation = model.find(params[:conversation_id])

        # görüşmenin önceki mesajlarını çek
        @messages = @conversation.messages.order(created_at: :desc)
                                                .limit(messages_amount)
                                                .offset(params[:messages_to_display_offset].to_i)
        # bir başka mesajları getir için bir değişken ayarla
        @messages_to_display_offset = params[:messages_to_display_offset].to_i + messages_amount

        @type = conversation_type.downcase
        # eğer görüşmede son mesajlar da çekilmişse değişkeni 0 yap
        # böylece daha fazla mesaj yükleme linki yok olur
        if @conversation.messages.count < @messages_to_display_offset
            @messages_to_display_offset = 0
        end 
    end

end

Burada active_support/concern içerip modülümüzü ActiveSupport::Concern ile genişlettiğimiz için Rails artık modülü bir concern olarak tanıyacaktır.

contantize metodu ile modül tanımını dinamik olarak bir stringden oluşturduk. Şu anda Private::Conversation modülü çıkacak ileride gruplar için Group::Conversation olduğunu da göreceğiz inşallah.

get_messages metodu tüm oluşum değişkenlerini ayarladıktan sonra index aksiyonumuz _load_more_messages.js.erb parça dosyasıyla çağrıya dönüş yapıyor.

Son olarak , mesajları listenin üstüne ekledikten sonra loading ikonunu yok etmeliyiz. _load_more_messages.js.erb dosyasının en altına şunu ekleyelim.

app/views/private/messages/_load_more_messages.js.erb
...
<%= render remove_link_to_messages %>

Şimdi Shared::MessagesHelper içinde remove_link_to_messages metodunu tanımlayalım.

app/helpers/shared/messages_helper.rb
...
    # eğer başka önceki mesaj kalmamışsa
    def remove_link_to_messages
        if @is_messenger == "false"
            if @messages_to_display_offset != 0
                "shared/empty_partial"
            else
                "shared/load_more_messages/window/remove_more_messages_link"
            end
        else
            "shared/empty_partial"
        end
    end
...

Gerekli parça dosyayı da tanımlayalım.

app/views/shared/load_more_messages/window/_remove_more_messages_link.js.erb
$("#<%= @id_type %><%= @conversation.id %> .loading-more-messages")
    .replaceWith("");
$("#<%= @id_type %><%= @conversation.id %> .load-more-messages")
    .replaceWith("");

Çalıştrımaya kalkınca yazarın private/conversations/_messages.html.erb dosyasını eklemeyi unuttuğunu gördüm. Bu dosyayı gruplar için daha sonra yazılan benzerinden üretmeye çalışayım.

app/views/private/conversations/_messages.html.erb
<% previous_message = nil %>
<% @messages.reverse.each do |message| %>
  <%= render  partial: 'private/messages/message', 
              locals: { user: message.user, 
                        conversation_id: @conversation.id,
                        message: message, 
                        previous_message: previous_message } %>
  <% previous_message = message %>
<% end %>

Şimdi geride başka mesaj kalmadığı için önceki mesajlara olan link ve loading ikonu kaldırılıyor.

Eğer şimdi bir mesaj gönderirsek görüşme penceresi içinde mesajımızla açılır. AJAX istemleriyle mesajları gösterebiliyoruz artık.






Action Cable ile gerçek zaman işlevselliği


Görüşme pencereleri oldukça güzel görünüyor. Bazı güzel işlevleri de var. Fakat en önemli özelliği eksik - gerçek zamanda mesaj gönderip almak.

Action Cable bize görüşme pencereleri için gereken gerçek zaman işlevlerini tanımlamakta yardımıcı olur.

İlk yapmamız gereken bir WebSocket bağlantısı oluşturmak ve özel bir kanala bağlamak. Şanslıyız ki WebSocket bağlantıları default Rails konfigürasyonu tarafından kapsanmıştır. app/channels/application_cable klasörü içine bakarsak channel.rb ve connection.rb dosyalarını görürüz. Connection sınıfı yetkilendirmeleri yönetir ve Channel sınıfı tüm kanallar arasında paylaşılan lojiği içeren üst sınıftır.

Connection default olarak kurulur. İhtiyacımız olan abone olacağımız bir görüşme kanalı. Namespace ile bir kanal oluşturmak için konsolda şunu girelim:

rails g channel private/conversation
Üretilen Private::ConversationChannel tanımında subscribed ve unsubscribed metodlarını görürüz. subscribed metodu ile kullanıcı kanala bir bağlantı yapar. unsubscribed metodu ile de kullanıcı kanala bağlantısını yok eder.

Bu metodların içeriğine şunları yazalım:

app/channels/private/conversation_channel.rb
class Private::ConversationChannel < ApplicationCable::Channel
  def subscribed
    stream_from "private_conversations_#{current_user.id}"
  end

  def unsubscribed
    stop_all_streams
  end
end

Burada kullanıcının kendisine ait bir kanalı olmasını istiyoruz. Bir kanaldan bir kullanıcı veri alıp verebilir. Kullanıcının id’si unique olduğundan kanal adına onu katarak kanalın da unique olmasını sağlıyoruz.

Bu server tarafındaki bir bağlantı. Şimdi client tarafta da bir bağlantı üretmeliyiz.

Client tarafta bağlantının bir oluşumunu tanımlamak için biraz JavaScript yazmamız gerekecek. Gerçekte Rails kanal üreteci kullandığımızda onu üretti. app/assets/javascripts/channels/private klasörüne gidelim. Rails default dosyayı CoffeeScript olarak oluşturmuş, biz burada JavaScript kullanacağız dosyanın adını conversation.js olarak değiştirelim ve içeriği şöyle düzenleyelim:

app/assets/javascripts/channels/private/conversation.js
App.private_conversation = App.cable.subscriptions.create("Private::ConversationChannel", {

  connected: function() {},

  disconnected: function() {},

  received: function(data) {}
});

Server’ı tekrar başlattığımda WebSocket bağlantısı yapılmaya çalışılırken conversation_channel.rb dosyasında subscribed metodu içinde current_user değişkeninin tanınmadığını gördüm (undefined variable error). Yazar burada bir şeyi atlamış. İnternette bir araştırma yapınca Devise gem kullanan yetkilendirmeli sitelerde Action Cable ile bağlantılar yapılacaksa connection.rb içinde current_user değişkeninin şöyle elde edildiğini gördüm.

app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user
 
    def connect
      self.current_user = find_verified_user
    end
 
    private
      def find_verified_user
        if verified_user = env['warden'].user
          verified_user
        else
          reject_unauthorized_connection
        end
      end
  end
end

Bu değişikliği yapalım ve server’ı tekrar başlatıp login olduğumuzda server log’da şunu görürüz.


Bağlantıyı gerçekleştirdik. Gerçek zamanlı iletişimin merkezini oturttuk. Artık sürekli açık bir client-server bağlantımız var. Bunun anlamı bağlantıyı yeniden yapmadan veya sayfayı yenilemeden server ile veri alışverişi yapabileceğiz. Eğer düşünürsek bu çok güçlü bir özellik. Buradan itibaren mesajlaşma sistemini bu bağlantı üzerinden devam ettireceğiz.

Görüşme penceresinin yeni mesaj formunu çalışır hale getirelim. assets/javascripts/channels/private/conversation.js dosyasına en alta şunları ilave edelim:

app/assets/javascripts/channels/private/conversation.js
...
$(document).on("submit", ".send-private-message", function(e) {
    e.preventDefault();
    var values = $(this).serializeArray();
    App.private_conversation.send_message(values);
    $(this).trigger("reset");
});

Fonksiyon yeni mesaj formundan verileri alıp send_message fonksiyonuna gönderiyor. send_message fonksiyonu server tarafında send_message metodunu çalıştıracak ve yeni mesaj üretilmesini yönetecek.

Diikat edersek olay işleyici bir submit butonu olayı. Ama bizim görüşme penceremizde bir submit butonu yok olabilir. Böyle tercih edilmiş. Burada klavyeden enter basıldığında submit butonunun tıklanmasını sağlayacağız. Bu fonksiyon ileride başka özellikler için de kullanılacak. Bu yüzden assets/javascripts/conversations klasörü altında conversation.js adında bir dosya ekleyelim.

app/assets/javascripts/conversations/conversation.js
$(document).on("turbolinks:load", function() {

    // görüşme penceresi scrollbar'ında üste bir aralık bırak
    $(".messages-list").scrollTop(500);

    // enter basılınca bir mesaj gönder ve mesaj penceresini orjinaline döndür
    $(document).on("keydown",
                    ".conversation-window, .conversation",
                    function(event) {
                        if (event.keyCode === 13) {
                            // eğer yazı varsa
                            if ($.trim($("textarea", this).val())) {
                                $(".send-message", this).click();
                                event.target.value = "";
                                event.preventDefault();
                            }
                        }
                    });
});

function calculateUnseenConversations() {
    var unseen_conv_length = $("#conversations-menu").find(".unseen-conv").length;
    if (unseen_conv_length) {
        $("#unseen-conversations").css("visibility", "visible");
        $("#unseen-conversations").text(unseen_conv_length);
    } else {
        $("#unseen-conversations").css("visibility", "hidden");
        $("#unseen-conversations").text("");
    }
}

Dosyaya kısaca bakarsak , önce scrollbarı yukarıdan uzaklaştırarak istenmeyen bir eski mesaj yüklemesinin önüne geçiliyor. Arkasından enter tuşu basılması ile submit butonu tıklanıyor ve mesaj alanı tekrar boşaltılıyor.

private_conversation nesnesi içinde send_message fonksiyonu ekleyerek başlayalım. received görev fonksiyonu altına şunu ekleyelim:

app/assets/javascripts/channels/private/conversation.js
  received: function(data) {},

  send_message: function(message) {
    return this.perform("send_message", {
        message: message
    });
  }
...

received fonksyonu satır sonuna “,” eklemeyi unutmayın. Bu fonksiyon server tarafında send_message metodunu çağırır ve message değerini ona gönderir. Server tarafı metodu Private::ConversationChannel sınıfı içinde tanımlanmalı. Metodu tanımlayalım:

app/channels/private/conversation_channel.rb
...
  def send_message(data)
    message_params = data["message"].each_with_object({}) do |el, hash|
        hash[el["name"]] = el["value"]
    end
    Private::Message.create(message_params)
  end
...

Bu metod yeni mesaj üretimini gerçekleştirecek. Burada data parametresi içinde gelen şey içiçe şeklinde bir hash. Bunu tek boyut has yapmak için each_with_object metodunu kullandık.

Eğer yeni bir mesaj göndermeye kalkarsak yeni mesaj kaydı otomatik olarak üretilir. Şu anda mesaj listesinde gösterilmez sayfayı yenilemek gerekir. Gösterilmesi için yeni eklenen mesajları yayınlayacak bişey yapmalıyız. Fakat öncesinde şu anda mesaj sisteminin nasıl çalıştığına bir bakalım.


  1. Kullanıcı yeni mesaj formunu doldurur ve mesajı gönderir
  2. javascripts/channels/private/conversation.js içindeki olay izleyici görüşme penceresinden veriyi alır, bir conversation_id ve mesaj içeriği, sonra client taraftaki send_message fonksiyonunu tetikler.
  3. Client taraftaki send_message fonksiyonu server taraftaki send_message metodunu çağırır ve verileri ona gönderir.
  4. Server taraftaki send_message metodu yeni bir Private::Message kaydı üretir.



Yeni mesajı yayınlamak


Yeni mesajı ürettik şimdi onu ilgili kanalda bir şekilde yayınlamamız gerekiyor. Active Record Görev Fonksiyonları bize modeller için çok kullanışlı metodlarla yardım eder. after_create_commit adında bir görev fonksiyonu var, bir modelde yeni bir kayıt oluşturulduğunda çalışır. Private::Message model dosyası içinde şunu ekleyelim:

app/models/private/message.rb
...
    after_create_commit do
        Private::MessageBroadcastJob.perform_later(self, previous_message)
    end

    def previous_message
        previous_message_index = self.conversation.messages.index(self) - 1
        self.conversation.messages[previous_message_index]
    end
...

Gördüğümüz gibi kayıt oluşturulmasından sonra Private::MessageBroadcastJob.preform_later metodu çağırılıyor. İyi de bu ne? Bu bir arkaplan görevi, arkaplanda dönen işleri yönetir. Bizim istediğimiz anlarda belli operasyonları çalıştırır. Belirli bir olayın hemen arkasından olabileceği gibi , belli zaman sonra ya da başka bir olayın arkasından gerçekleştirilebilir. Active Job temellerini okuyabilirsiniz.

Şimdi yeni üretilen mesajı özel görüşmenin kanalında yayınlayacak olan bu arkaplan görevini yazabiliriz. Konsolda şunu girelim

rails g job private/message_broadcast
Üretilen dosya içinde perform metodunu görürüz. Bir görevi çağırdığımızda default olarak bu metod çalışır. Şimdi görev içinde verileri işleyelim ve kanalın abonelerine iletelim.

app/jobs/private/message_broadcast_job.rb
class Private::MessageBroadcastJob < ApplicationJob
  queue_as :default

  def perform(message, previous_message)
    sender = message.user
    recipient = message.conversation.opposed_user(sender)

    broadcast_to_sender(sender, recipient, message, previous_message)
    broadcast_to_recipient(sender, recipient, message, previous_message)
  end

  private

    def broadcast_to_sender(sender, recipient, message, previous_message)
        ActionCable.server.broadcast(
            "private_conversations_#{sender.id}",
            message: render_message(message, previous_message, sender),
            conversation_id: message.conversation_id,
            recipient_info: recipient)
    end

    def broadcast_to_recipient(sender, recipient, message, previous_message)
        ActionCable.server.broadcast(
            "private_conversations_#{recipient.id}",
            recipient: true,
            sender_info: sender,
            message: render_message(message, previous_message, recipient),
            conversation_id: message.conversation_id)
    end

    def render_message(message, previous_message, user)
        ApplicationController.render(
            partial: "private/messages/message",
            locals: { message: message,
                      previous_message: previous_message,
                      user: user }
            )
    end

end

Burada bir mesaj yayınlıyoruz ve kanalın her iki abonesine de gönderiyoruz. Ayrıca mesajın sağlıklı gösterildiğinden emin olmak için bazı key-value çiftleri de gönderiyoruz. Şimdi mesaj taraflara gönderildi de hala listeye gelmez. Çünkü client taraf görselleri için henüz bir şey yapmadık. Bir kanalda bir veri yayınlandığında karşıdaki client’da received görev fonksiyonu tetiklenir. İşte burada verileri DOM’a ekleyerek görünmesini sağlayacağız. received fonksiyonu içine şunları ekleyelim:

app/assets/javascripts/channels/private/conversation.js
  received: function(data) {
    // Görüşmeler menüsü listesinde bu görüşmeye link varsa
    // o linki görüşmeler menüsünde en üste taşı
    var conversation_menu_link = $("#conversations-menu ul")
          .find("#menu-pc" + data["conversation_id"]);

    if (conversation_menu_link.length) {
      conversation_menu_link.prependTo("#conversations-menu ul");
    }

    // değişkenleri ayarla
    var conversation = findConv(data["conversation_id"], "p");
    var conversation_rendered = ConvRendered(data["conversation_id"], "p");
    var messages_visible = ConvMessagesVisiblity(conversation);

    if (data["recipient"] == true) {
      // yeni mesaj alındıktan sonra "görülmedi" olarak işaretle
      $("#menu-pc" + data["conversation_id"]).addClass("unseen-conv");

      // Eğer görüşme penceresi mevcutsa 
      if (conversation_rendered) {
        if (!messages_visible) {
          // Görüşme penceresi stilini değiştirerek görülmemiş mesajı belirt
          // Bir sınıf falan ekleyerek bunu gerçekleştir
        }
        conversation.find(".messages-list").find("ul").append(data["message"]);
      }

      calculateUnseenConversations();
    }
    else {
      conversation.find("ul").append(data["message"]);
    }

    if (conversation.length) {
      // yeni mesaj eklendikten sonra pencerede aşağıya scroll et
      var messages_list = conversation.find(".messages-list");
      var height = messages_list[0].scrollHeight;
      messages_list.scrollTop(height);
    }
  },

Arada görülmemiş mesaj olan pencereye ilgi çekilecek bir yere sadece yorum yazarak sonraya bıraktık.

Burada findConv , ConvRendered ve ConvMessagesVisibility fonksiyonları kullanılmış. Bu fonksiyonları hem özel hem grup görüşmelerde kullanacağız.
Bu fonksiyonları koymak için ortak kullanıldığı belli olsun diye shared adında bir klasör ekleyelim.

app/assets/javascripts/channels/shared
Bu klasör altında conversation.js dosyasını ekleyelim.

app/assets/javascripts/channels/shared/conversation.js
// DOM içinde bir görüşmeyi bulur
function findConv(conversation_id, type) {
    // mesajlaşmada bir açık görüşme varsa
    var messenger_conversation = $("body .conversation");
    if (messenger_conversation.length) {
        // mesajlaşmada açık görüşme var
        return messenger_conversation;
    } else {
        // görüşme bir popup pencerede açık
        var data_attr = "[data-" + type + "conversation-id='" + 
                            conversation_id + "']";
        var conversation = $("body").find(data_attr);
        return conversation;
    }
}

// bir görüşme penceresinin yayınlanmış ve tarayıcıda görünüyor olmasını teyit eder
function ConvRendered(conversation_id, type) {
    // eğer mevcut görüşme mesajlaşmada açılmışsa
    if ($("body .conversation").length) {
        // görüşme mesajlaşmada açılmış
        // o zaman görünür demektir
        return true;
    } else {
        // görüşme popup pencerede açılmış
        // görüşme bir popup pencerede açık
        var data_attr = "[data-" + type + "conversation-id='" + 
                            conversation_id + "']";
        var conversation = $("body").find(data_attr);
        return conversation.is(":visible");
    }
}

function ConvMessagesVisiblity(conversation) {
    // eğer görüşme mesajlaşmada açıksa
    if ($("body .conversation").length) {
        // görüşme , mesajlaşmada açık
        // öyleyse görünebilir vaziyettedir
        return true;
    } else {
        // görüşme popup pencerede açık
        // pencerenin toplanmış mı genişletilmiş mi olduğuna bak
        var visiblity = conversation
                            .find(".panel-body")
                            .is("visible");
        return visiblity;
    }
}

Kodda mesajlaşma olarak bilinen bir sayfadan bahsediliyor. Bu görüntü şu anda uygulamada mevcut değil. Mesajlaşma , görüşmeleri yapmak için başka bir yol olarak uygulamamız daha sonra eklenecek. İleride bir sürü küçük küçük ilave yaparken konudan kopmamak adına şimdi kodun tamamı verilmiş.

Hepsi bu kadar gerçek zamanlı işlevsellik çalışıyor olmalı. Gönderen de alan da her iki kullanıcı da yeni mesajları görebiliyor olmalı.

Ama burada sanırım farkettiniz can sıkıcı bir konu var. Biz açık olan görüşme pencerelerinin bilgisini oturum bilgileri içine sakladığımızdan dolayı oturumu sonlandırdığımızda görüşme pencereleri yok oluyor ve tekrar geri getiremiyoruz. Ancak yeni görüşme başlatınca oturum bilgilerine o görüşme penceresi ekleniyor. Bu sorunu çözmek için ileride yol yapılacak. Ama ben geçici olarak kayıtlı görüşmeleri siliyorum ve sıfırdan yeni görüşme başlatıyorum. Bunu nasıl yapacağımızı kısaca anlatayım. Önce konsolda Rails consol uygulamasını çalıştırıyoruz.

rails console
Sonra Rails konsolda görüşmeleri listeletiyoruz.

Private::Conversation.all
irb(main):014:0> Private::Conversation.all
  Private::Conversation Load (2.0ms)  SELECT  "private_conversations".* FROM "private_conversations" LIMIT ?  [["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Private::Conversation id: 7, recipient_id: 7, sender_id: 1, created_at: "2020-05-17 11:15:19", updated_at: "2020-05-17 11:15:19">, 
#<Private::Conversation id: 8, recipient_id: 2, sender_id: 1, created_at: "2020-05-19 09:57:29", updated_at: "2020-05-19 09:57:29">]>
Bu örnekte id değeri 7 ve 8 olan 2 kayıt bulundu. Kısaca bunları siliyorum.

Private::Conversation.delete(7)
Private::Conversation.delete(8)

Sonra gidip sayfadan kullanıcı ile yeni görüşme açıyorum. Ancak bu da çözüm olmadı çünkü karşı tarafı da göreyim dediğimde onun oturumunu başka bir tarayıcıda açınca bu sefer ona mesajlar gelmedi. Bu sefer pansuman tedbirimi geliştirmeye karar verdim. Önce gittim ana yerleşim dosyasına bir link ekleyerek görüşmeleri getirmesi için bir aksiyon çağırdım.

app/views/layouts/application.html.erb
...
  <body>
    <%= render "layouts/navigation" %>
    <%= yield %>
    <%= render "layouts/application/private_conversations_windows" %>
    <%= link_to "Görüşmeleri yükle",
                action: "refresh",
                controller: "private/conversations" %>
  </body>
...

Linki koyduk ama routes.rb içinde bunu tanımlamazsak route yok hatası verecek. routes.rb en alta şunu ekledim

config/routes.rb
...
    resources :messages, only: [:index, :create]
  end

  # olan görüşmeleri getirme linki
  get "private/conversations/refresh"
  
end

Şimdi conversations_controller.rb içine ekleyeceğim aksiyon ile kullanıcının dahil olduğu görüşmeleri getirebilirim.

app/controllers/private/conversations_controller.rb
...
    def refresh
        convs = Private::Conversation.where("sender_id = ? or recipient_id = ?", current_user.id, current_user.id)
        for conv in convs
            @conversation = conv
            add_to_conversations unless already_added?
        end
        redirect_to "/"
    end
...

İlk önce oturum açmış olan kullanıcının kayıtlı görüşmeler içinde gönderen ya da alan tarafında olduğu tüm görüşmeleri çekiyorum. Bu arada bu pansuman tedbir olduğu için kullanıcı giriş yaptı mı yapmadı mı bakmıyorum, giriş yapmış kabul ediyorum. Sonra bu görüşmelerin her birini yukarıdaki create aksiyonundan kopyaladığım şekil bir kodla oturuma dahil ediyorum. Oh çok şükür, artık kapanmış tüm görüşme pencereleri bir tıkla emrimde. En son olarak da ana sayfaya yönlendiriyorum, yoksa tarayıcı adresinde saçma bir şey yazacak.

Aksak topal mesajlar karşıya gitmeye başladı.




Şimdi mesajlar karşı tarafa ve bize nasıl geliyor kısaca üzerinden geçelim.


  1. Yeni Private::Message kaydı oluştuktan sonra after_create_commit metodu tetikleniyor ve arkaplan görevini çağırıyor.
  2. Private::MessageBroadcastJob> verilen veriyi işler ve kanal abonelerine yayınlar.
  3. Client tarafta received görev fonksiyonu çalışarak veriyi DOM’a ekler.
Bu kadarıyla keselim ve bu yazıyı yayınlayalım çünkü çok uzadı. Sonraki bölümde mesajlaşma yapısını devam edeceğiz inşallah. O zamana kadar kalın sağlıcakla..



Hiç yorum yok:

Yorum Gönder