diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index bc8ddb5..db20435 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/Gemfile b/Gemfile index 84a5158..ef4975e 100644 --- a/Gemfile +++ b/Gemfile @@ -75,9 +75,6 @@ gem "postmark-rails" # Subscription management gem "stripe" -# Chat -gem "stream-chat-ruby" - # Markdown support gem "redcarpet" diff --git a/Gemfile.lock b/Gemfile.lock index b76e605..32bff38 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -85,7 +85,6 @@ GEM regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) concurrent-ruby (1.2.2) - connection_pool (2.4.1) content_disposition (1.0.0) crass (1.0.6) cssbundling-rails (1.3.3) @@ -119,9 +118,6 @@ GEM faraday-multipart (1.0.4) multipart-post (~> 2) faraday-net_http (3.0.2) - faraday-net_http_persistent (2.1.0) - faraday (~> 2.5) - net-http-persistent (~> 4.0) ffi (1.16.1) globalid (1.2.1) activesupport (>= 6.1) @@ -193,8 +189,6 @@ GEM msgpack (1.7.2) multi_json (1.15.0) multipart-post (2.3.0) - net-http-persistent (4.0.2) - connection_pool (~> 2.2) net-imap (0.3.7) date net-protocol @@ -316,7 +310,6 @@ GEM faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 3.0) multi_json (~> 1.10) - sorbet-runtime (0.5.11074) sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) @@ -326,13 +319,6 @@ GEM sprockets (>= 3.0.0) stimulus-rails (1.2.2) railties (>= 6.0.0) - stream-chat-ruby (3.0.0) - faraday - faraday-multipart - faraday-net_http_persistent - jwt - net-http-persistent - sorbet-runtime stringio (3.0.8) stripe (9.4.0) thor (1.2.2) @@ -391,7 +377,6 @@ DEPENDENCIES shrine-google_cloud_storage sprockets-rails stimulus-rails - stream-chat-ruby stripe turbo-rails tzinfo-data diff --git a/app/assets/stylesheets/application.bootstrap.scss b/app/assets/stylesheets/application.bootstrap.scss index 7bc1b3e..6bf2c8b 100644 --- a/app/assets/stylesheets/application.bootstrap.scss +++ b/app/assets/stylesheets/application.bootstrap.scss @@ -4,12 +4,48 @@ $primary: #262626; @import 'bootstrap/scss/bootstrap'; @import 'bootstrap-icons/font/bootstrap-icons'; +@import 'channels'; @import 'chats'; @import 'home'; @import 'location'; +@import 'profiles'; @import 'search_location'; @import 'visas'; .navbar-actions { display: flex; } + +.profile-picture { + border-radius: 50%; + + height: 180px; + width: 180px; + + &.chat-profile-picture { + margin-right: 4px; + + height: 36px; + width: 36px; + + &.placeholder { + font-size: 1rem; + } + } + + &.placeholder { + display: flex; + align-items: center; + justify-content: center; + + background-color: lighten($primary, 20%); + color: $white; + + font-size: 5rem; + + &:hover { + cursor: default; + } + } +} + diff --git a/app/assets/stylesheets/channels.scss b/app/assets/stylesheets/channels.scss new file mode 100644 index 0000000..d292cc2 --- /dev/null +++ b/app/assets/stylesheets/channels.scss @@ -0,0 +1,137 @@ +.chat-channel-section { + flex: 1; + + display: flex; + flex-direction: column; + + .channel-header { + flex: 0; + + display: flex; + flex-direction: row; + + border-bottom: 1px solid $primary; + + .leave-channel-button { + color: $danger; + } + } + + .channel-message-section { + flex: 1 0 0; + overflow: scroll; + + display: flex; + flex-direction: column-reverse; + + .chat-message { + display: flex; + flex-direction: row; + + align-items: center; + + margin: 0 0.25rem 0.25rem 0.25rem; + + .message-actions-container { + display: none; + flex-direction: row; + align-items: center; + + a, button { + display: flex; + } + + .delete-icon { + color: $danger; + } + } + + &:hover { + .message-actions-container { + display: flex; + } + } + + p { + max-width: 50vw; + margin: 0; // reset margin + padding: 0.5rem 0.75rem; + border-radius: 1.5rem; + + color: $white; + background-color: $primary; + } + + &.chat-message-current-user { + display: flex; + flex-direction: row-reverse; + } + + .message-body { + white-space: pre-line; + } + + .replying-to-link { + border-bottom: 1px solid $white; + } + + .replying-to { + color: $white; + font-size: 0.75rem; + } + } + + .load-more-link-container, .start-of-conversation { + display: flex; + justify-content: center; + align-items: center; + + margin-top: 0.25rem; + } + } + + .channel-message-form-section { + flex: 0; + border-top: 1px solid $primary; + + .channel-message-form { + display: flex; + flex-direction: row; + + .channel-message-textarea { + flex: 1; + } + + .channel-message-button-container { + min-width: 6rem; + flex: 0; + } + } + + .reply-to-section { + display: none; + flex-direction: row; + + p { + flex: 1; + } + } + + .error-scroll-to-reply { + display: none; + color: $danger; + } + + .join-channel-section { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + } + + .message-sender { + font-size: 0.75rem; + margin-left: 44px; // align with message + } +} diff --git a/app/assets/stylesheets/chats.scss b/app/assets/stylesheets/chats.scss index 013c701..6d929ff 100644 --- a/app/assets/stylesheets/chats.scss +++ b/app/assets/stylesheets/chats.scss @@ -1,43 +1,63 @@ -@import 'stream-chat-react/dist/scss/v2/index.scss'; - body { height: 100vh; -} + max-height: 100vh; -.chat-root { display: flex; + flex-direction: column; - .str-chat__channel { - flex: 1; + .navbar { + flex: 0; } - .str-chat-channel-list { - width: 20vw; - } -} + .chat-screen-container { + flex: 1; -.str-chat { - --str-chat__primary-color: #262626; - --str-chat__own-message-bubble-background-color: #262626; - --str-chat__own-message-bubble-color: white; -} + .chat-message-section { + display: flex; + flex-direction: column; -.str-chat__message--me { - --str-chat__message-mention-color: white; -} + border-left: 1px solid $primary; -.str-chat__message-input { - position: abolute; - bottom: 0.5rem; -} + &.no-chat { + justify-content: center; + align-items: center; + } + } -.str-chat__list { - min-height: 75vh; - max-height: 75vh; - overflow: scroll; -} + .channel-list-container { + display: flex; + flex-direction: column; + + .channel-list { + flex: 1; + + display: flex; + flex-direction: column; + + .channel-links-container { + flex: 1 0 0; + overflow: scroll; + + display: flex; + flex-direction: column; -.str-chat__thread { - max-width: 30vw; + .channel-link { + border-bottom: 1px solid $primary; + text-decoration: none; + + &:hover { + background-color: lighten($primary, 70%); + } + } + } + + .channel-list-actions { + flex: 0; + + display: grid; + } + } + } + } } diff --git a/app/assets/stylesheets/profiles.scss b/app/assets/stylesheets/profiles.scss new file mode 100644 index 0000000..e69de29 diff --git a/app/assets/stylesheets/visas.scss b/app/assets/stylesheets/visas.scss index be88ad9..7610a70 100644 --- a/app/assets/stylesheets/visas.scss +++ b/app/assets/stylesheets/visas.scss @@ -1,4 +1,4 @@ -.delete-country-button { +.delete-button { background: none; color: inherit; border: none; diff --git a/app/controllers/channel_members_controller.rb b/app/controllers/channel_members_controller.rb new file mode 100644 index 0000000..8a9accb --- /dev/null +++ b/app/controllers/channel_members_controller.rb @@ -0,0 +1,62 @@ +class ChannelMembersController < ApplicationController + def create + @channel = Channel.find(create_channel_member_params[:channel_id]) + @user = User.find(create_channel_member_params[:user_id]) + + @member = ChannelMember.new( + chat_channel: @channel, + user: @user + ) + + if @member.save + redirect_to @channel + else + flash[:error_join_channel] = "Couldn't join channel at this time. Please try again" + + redirect_to @channel + end + end + + def destroy + @member = ChannelMember.find(params[:id]) + + authorize(@member) + @member.destroy! + + redirect_to chat_path + end + + def update_last_active + @member = ChannelMember.find(params[:id]) + + authorize(@member) + + previous_last_active = @member.last_active + @member.update!(last_active: Time.now) + + respond_to do |format| + format.turbo_stream do + if previous_last_active < @member.chat_channel.last_action_at + render turbo_stream: turbo_stream.replace( + "channel-list", + partial: "chats/current_user_channel_list", + locals: { + user: current_user + } + ) + end + end + end + rescue StandardError + render json: {}, status: 500 + end + + private + + def create_channel_member_params + params.require(:channel_member).permit( + :channel_id, + :user_id + ) + end +end diff --git a/app/controllers/channel_messages_controller.rb b/app/controllers/channel_messages_controller.rb new file mode 100644 index 0000000..29bc884 --- /dev/null +++ b/app/controllers/channel_messages_controller.rb @@ -0,0 +1,165 @@ +class ChannelMessagesController < ApplicationController + include Pagy::Backend + + def index + @channel = Channel.find(params[:channel_id]) + + authorize(@channel, :show?) + + @pagy, @messages = pagy( + @channel.messages.order(created_at: :desc), + items: 50, + page: params[:page] + ) + + respond_to do |format| + format.turbo_stream do + render turbo_stream: turbo_stream.prepend( + "channel-messages", + partial: "channels/chat_messages", + locals: { + messages: @messages, + channel: @channel, + user: current_user + } + ) + end + end + rescue Pagy::OverflowError + respond_to do |format| + format.turbo_stream do + render turbo_stream: turbo_stream.replace( + "load-more-link", + partial: "channels/start_of_conversation" + ) + end + end + end + + def create + @channel = Channel.find(params[:channel_id]) + @message = ChannelMessage.new( + body: channel_message_params[:body], + reply_to_id: channel_message_params[:reply_to_id], + sender: current_user, + channel: @channel + ) + + authorize(@message) + + if @message.save + # Update channel member + ChannelMember + .find_by!( + user: current_user, + chat_channel: @channel + ) + .update!( + last_active: Time.now + ) + + # Update channel activity + @channel.update!(last_action_at: Time.now) + + # Update users of the channel + # NOTE this should probably be a background job + @channel.channel_members.each do |channel_member| + user = channel_member.user + next if current_user == user + + Turbo::StreamsChannel.broadcast_action_to( + "user-#{channel_member.user_id}-navbar-chat-link", + action: :replace, + target: "navbar-chat-link", + partial: "layouts/navbar/chat_link", + locals: { + user: user, + } + ) + + Turbo::StreamsChannel.broadcast_action_to( + "user-#{channel_member.user_id}-chat", + action: :replace, + target: "channel-list", + partial: "chats/current_user_channel_list", + locals: { + user: user, + } + ) + + Turbo::StreamsChannel.broadcast_action_to( + "user-#{channel_member.user_id}-channel-#{@channel.id}", + action: :append, + target: "channel-messages", + partial: "channels/chat_message", + locals: { + message: @message, + channel: @channel, + user: user + } + ) + end + end + + # If message.save fails, the partial handles the error messaging + # Else just re-render the section and show the sent message + respond_to do |format| + @messages = @channel.messages.order(created_at: :desc).limit(50) + + format.turbo_stream + end + end + + def destroy + @channel = Channel.find(params[:channel_id]) + @message = @channel.messages.find_by!( + id: params[:id], + channel: @channel, + sender: current_user + ) + + authorize(@message) + + @message.update!(deleted: true) + + @channel.channel_members.each do |channel_member| + user = channel_member.user + next if current_user == user + + Turbo::StreamsChannel.broadcast_action_to( + "user-#{channel_member.user_id}-channel-#{@channel.id}", + action: :replace, + target: "chat-message-#{@message.id}", + partial: "channels/chat_message", + locals: { + message: @message, + channel: @channel, + user: user + } + ) + end + + respond_to do |format| + format.turbo_stream do + render turbo_stream: turbo_stream.replace( + "chat-message-#{@message.id}", + partial: "channels/chat_message", + locals: { + message: @message, + channel: @channel, + user: current_user + } + ) + end + end + end + + private + + def channel_message_params + params.require(:channel_message).permit( + :body, + :reply_to_id + ) + end +end diff --git a/app/controllers/channels_controller.rb b/app/controllers/channels_controller.rb new file mode 100644 index 0000000..09e8b55 --- /dev/null +++ b/app/controllers/channels_controller.rb @@ -0,0 +1,113 @@ +class ChannelsController < ApplicationController + include Pagy::Backend + + layout false, only: [:show] + + def show + @channel = Channel.find(params[:id]) + + authorize(@channel) + + @channels = current_user.chat_channels + @messages = @channel.messages.order(created_at: :desc).limit(50) + @message = ChannelMessage.new + + if @channel.include?(user: current_user) + # NOTE this should probably be a background job + @channel + .channel_members + .find_by!(user: current_user) + .update!(last_active: Time.now) + + Turbo::StreamsChannel.broadcast_action_to( + "user-#{current_user}-navbar-chat-link", + action: :replace, + target: "navbar-chat-link", + partial: "layouts/navbar/chat_link", + locals: { + user: current_user + } + ) + end + + render "chats/show" + end + + def new + @channel = Channel.new + + authorize(@channel) + end + + def create + @channel = Channel.new( + channel_params.merge({ + last_action_at: Time.now + }), + ) + + authorize(@channel) + + if @channel.save + # Add the admin who created the channel to it + ChannelMember.create!( + user: current_user, + chat_channel: @channel + ) + + redirect_to chat_path + else + flash[:error_create_channel] = "Couldn't create channel right now. Please try again" + + render :new + end + end + + def joinable + # TODO the query below isn't working - so write a less + # efficient query for now + #@channels = Channel + # .left_joins(:channel_members) + # .where.not(channel_members: { user_id: current_user.id }) + current_user_channels = Channel + .joins(:channel_members) + .where("channel_members.user_id = ?", current_user.id) + .pluck(:id) + + @channels = Channel + .where(id: Channel.pluck(:id) - current_user_channels) + .order(:last_action_at) + + respond_to do |format| + format.turbo_stream do + render turbo_stream: turbo_stream.replace( + "channel-list", + partial: "chats/joinable_channel_list", + locals: { channels: @channels } + ) + end + end + end + + def current_user_list + respond_to do |format| + format.turbo_stream do + render turbo_stream: turbo_stream.replace( + "joinable-channel-list", + partial: "chats/current_user_channel_list", + locals: { + user: current_user + } + ) + end + end + end + + private + + def channel_params + params.require(:channel).permit( + :name + ) + end +end diff --git a/app/controllers/chats_controller.rb b/app/controllers/chats_controller.rb index a7ecf48..c748eea 100644 --- a/app/controllers/chats_controller.rb +++ b/app/controllers/chats_controller.rb @@ -3,51 +3,36 @@ class ChatsController < ApplicationController layout false, only: [:show] - before_action :authenticate_subscription! + before_action :authenticate_subscription!, only: [:show] def show - if (ENV["STREAM_API_KEY"] && ENV["STREAM_API_SECRET"]).present? - if current_user.stream_user_id.nil? - @stream_user_id = SecureRandom.hex(16) - @stream_user_token = StreamChatClient.create_stream_user( - id: @stream_user_id - ) + add_user_to_default_channels + + @channels = current_user.chat_channels + end - # TODO error handling - current_user.update!( - stream_user_id: @stream_user_id, - stream_user_token: @stream_user_token + def navbar_link + respond_to do |format| + format.turbo_stream do + render turbo_stream: turbo_stream.replace( + "navbar-chat-link", + partial: "layouts/navbar/chat_link", + locals: { + user: current_user + } ) - else - @stream_user_id = current_user.stream_user_id - @stream_user_token = current_user.stream_user_token end - - add_current_user_to_channels - else - @stream_env_vars_missing = true end end private - def add_current_user_to_channels - [ - { type: "messaging", id: "general" }, - { type: "messaging", id: "feedback-and-requests" }, - { type: "messaging", id: "bugs" }, - ].each do |channel| - channel = StreamChatClient.get_channel( - type: channel[:type], - channel_id: channel[:id], - ) - unless StreamChatClient.channel_include?( - channel: channel, - user_id: current_user.stream_user_id - ) - StreamChatClient.add_member( - channel: channel, - user_id: current_user.stream_user_id + def add_user_to_default_channels + if current_user.chat_channels.empty? + Channel::DEFAULT_CHAT_CHANNELS.each do |channel_name| + ChannelMember.create!( + chat_channel: Channel.find_by!(name: channel_name), + user: current_user ) end end diff --git a/app/controllers/profile_pictures_controller.rb b/app/controllers/profile_pictures_controller.rb new file mode 100644 index 0000000..b314996 --- /dev/null +++ b/app/controllers/profile_pictures_controller.rb @@ -0,0 +1,71 @@ +class ProfilePicturesController < ApplicationController + def create + @user = User.find(params[:user_id]) + + authorize(@user, :edit?) + + @profile_picture = ProfilePicture.new( + user: @user, + image: profile_picture_params[:image] + ) + + if @profile_picture.save + redirect_to profile_path + else + flash[:error_upload_profile_picture] = "Couldn't upload profile picture. Please try again" + + redirect_to profile_path + end + end + + def update + @user = User.find(params[:user_id]) + + authorize(@user, :edit?) + + @user.profile_picture.assign_attributes( + image: profile_picture_params[:image] + ) + + if @user.profile_picture.save + redirect_to profile_path + else + flash[:error_upload_profile_picture] = "Couldn't upload profile picture. Please try again" + + redirect_to profile_path + end + end + + def upload_modal + @user = User.find(params[:user_id]) + + authorize(@user, :edit?) + + if @user.profile_picture.present? + @profile_picture = @user.profile_picture + else + @profile_picture = ProfilePicture.new + end + + respond_to do |format| + format.turbo_stream do + render turbo_stream: turbo_stream.append( + "site-modals", + partial: "profile_pictures/upload_modal", + locals: { + user: @user, + profile_picture: @profile_picture + } + ) + end + end + end + + private + + def profile_picture_params + params.require(:profile_picture).permit( + :image + ) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be79..5557d97 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,12 @@ module ApplicationHelper + def helper_user_initials(user:) + split_display_name = user.display_name.upcase.split(" ") + if split_display_name.length >= 2 + "#{split_display_name[0][0]}#{split_display_name[1][0]}" + elsif split_display_name[0].length >= 2 + "#{split_display_name[0][0]}#{split_display_name[0][1]}" + else + "#{split_display_name[0]}" + end + end end diff --git a/app/helpers/chats_helper.rb b/app/helpers/chats_helper.rb new file mode 100644 index 0000000..fa7c116 --- /dev/null +++ b/app/helpers/chats_helper.rb @@ -0,0 +1,9 @@ +module ChatsHelper + def helper_short_message(message:) + if message.length >= 50 + message[0..47] + "..." + else + message + end + end +end diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb new file mode 100644 index 0000000..4e43050 --- /dev/null +++ b/app/helpers/profiles_helper.rb @@ -0,0 +1,2 @@ +module ProfilesHelper +end diff --git a/app/javascript/application.js b/app/javascript/application.js index 902050a..3016c21 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -2,5 +2,3 @@ import "@hotwired/turbo-rails" import "./controllers" import * as bootstrap from "bootstrap" - -import "./components" diff --git a/app/javascript/components/Chat/index.jsx b/app/javascript/components/Chat/index.jsx deleted file mode 100644 index 43e4698..0000000 --- a/app/javascript/components/Chat/index.jsx +++ /dev/null @@ -1,78 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { createRoot } from "react-dom/client"; -import { StreamChat } from 'stream-chat'; -import { - Chat as StreamChatComponent, - Channel, - ChannelHeader, - ChannelList, - MessageList, - MessageInput, - Thread, - Window, -} from 'stream-chat-react'; - -const rootElement = document.getElementById("chat-root"); -const userId = rootElement?.dataset.streamUserId; -const displayName = rootElement?.dataset.displayName; -const userToken = rootElement?.dataset.streamUserToken; - -const filters = { type: 'messaging', members: { $in: [userId]} }; -const options = { state: true, presence: true }; -const sort = { last_message_at: -1 }; - -const Chat = () => { - const [client, setClient] = useState(null); - - useEffect(() => { - const newClient = new StreamChat('s3u4gjg6hnj2'); - - const handleConnectionChange = ({ online = false }) => { - if (!online) return console.log('connection lost'); - setClient(newClient); - }; - - newClient.on('connection.changed', handleConnectionChange); - - newClient.connectUser( - { - id: userId, - name: displayName, - }, - userToken, - ); - - return () => { - newClient.off('connection.changed', handleConnectionChange); - newClient.disconnectUser().then(() => console.log('connection closed')); - }; - }, []); - - if (!client) return null; - - return ( - - - - - - - - - - - - ); -} - -if (rootElement) { - const root = createRoot(rootElement); - root.render(); -} diff --git a/app/javascript/components/index.js b/app/javascript/components/index.js deleted file mode 100644 index c437119..0000000 --- a/app/javascript/components/index.js +++ /dev/null @@ -1 +0,0 @@ -import "./Chat" diff --git a/app/javascript/controllers/chat_channel_controller.js b/app/javascript/controllers/chat_channel_controller.js new file mode 100644 index 0000000..edd33ee --- /dev/null +++ b/app/javascript/controllers/chat_channel_controller.js @@ -0,0 +1,108 @@ +import { Controller } from "@hotwired/stimulus"; +import { Modal } from "bootstrap"; +import Rails from "@rails/ujs"; + +export default class extends Controller { + static targets = [ + "hiddenReplyToField", + "messageTextarea", + "replyDisplayName", + "replyMessageBody" + ]; + static values = { + channelMemberId: String, + }; + + connect() { + if (!!this.channelMemberIdValue) { + setInterval(() => { + fetch( + `/channel_members/${this.channelMemberIdValue}/update_last_active`, + { + method: "PATCH", + credentials: "same-origin", + headers: { "X-CSRF_Token": Rails.csrfToken() }, + format: "TURBO_STREAM" + } + ) + .then((response) => { + if (response.ok) { + return response.text() + } else { + const modal = document.getElementById("disconnected-modal"); + + if (modal.style.display != "block") { + new Modal( + modal, + {} + ).show(); + } + } + }) + .then((html) => { + if (!html) return; + Turbo.renderStreamMessage(html); + }); + }, 10000); + } + } + + setReplyTo(event) { + const messageId = event.currentTarget.dataset.messageId; + const messageSender = event.currentTarget.dataset.messageSender; + // Just use the first line + let messageBody = event.currentTarget.dataset.messageBody.split("\n")[0]; + + // If the first line is long, just get the first 47 characters then + // "..." + if (messageBody.length > 150) { + messageBody = messageBody.slice(0, 147) + "..."; + } + + document.getElementById("reply-to-section").style.display = "flex"; + + this.hiddenReplyToFieldTarget.value = messageId; + this.replyDisplayNameTarget.innerText = messageSender; + this.replyMessageBodyTarget.innerText = messageBody; + this.messageTextareaTarget.focus(); + } + + clearReplyTo() { + document.getElementById("reply-to-section").style.display = "none"; + + this.hiddenReplyToFieldTarget.value = null; + this.replyDisplayNameTarget.innerText = ""; + this.replyMessageBodyTarget.innerText = ""; + } + + jumpToReply(event) { + const replyToId = event.currentTarget.dataset.replyToId; + + const message = document.getElementById(`chat-message-${replyToId}`); + + if (!!message) { + message.scrollIntoView(); + this._flashMessage(message); + } else { + const errorMessage = document.getElementById("error-scroll-to-reply"); + errorMessage.style.display = "block"; + + // NOTE: could probably animate fade... + setTimeout(() => { + errorMessage.style.display = "none"; + }, 3000); + } + } + + _flashMessage(message) { + // Set the background color... + // this is the same as lighten($primary, 70%) + message.style.backgroundColor = "#d9d9d9"; + + // Then switch to original again + // NOTE: could probably animate... + setTimeout(() => { + message.style.backgroundColor = ""; + }, 3000); + } +} diff --git a/app/javascript/controllers/chat_textarea_controller.js b/app/javascript/controllers/chat_textarea_controller.js new file mode 100644 index 0000000..9ea3ab6 --- /dev/null +++ b/app/javascript/controllers/chat_textarea_controller.js @@ -0,0 +1,12 @@ +import { Controller } from "@hotwired/stimulus" +import { Modal } from "bootstrap" + +export default class extends Controller { + static targets = [ + "textarea" + ]; + + resizeTextarea() { + this.textareaTarget.rows = this.textareaTarget.value.split("\n").length; + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 994e88d..b17fc6f 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -4,14 +4,26 @@ import { application } from "./application" +import ChatChannelController from "./chat_channel_controller" +application.register("chat-channel", ChatChannelController) + +import ChatTextareaController from "./chat_textarea_controller" +application.register("chat-textarea", ChatTextareaController) + +import JoinableChannelListController from "./joinable_channel_list_controller" +application.register("joinable-channel-list", JoinableChannelListController) + +import LoadMoreController from "./load_more_controller" +application.register("load-more", LoadMoreController) + import LocationTabsController from "./location_tabs_controller" application.register("location-tabs", LocationTabsController) import ModalController from "./modal_controller" application.register("modal", ModalController) -import NavbarController from "./navbar_controller" -application.register("navbar", NavbarController) +import NavbarLinkController from "./navbar_link_controller" +application.register("navbar-link", NavbarLinkController) import SearchEligibleCountriesController from "./search_eligible_countries_controller" application.register("search-eligible-countries", SearchEligibleCountriesController) diff --git a/app/javascript/controllers/joinable_channel_list_controller.js b/app/javascript/controllers/joinable_channel_list_controller.js new file mode 100644 index 0000000..738ea3d --- /dev/null +++ b/app/javascript/controllers/joinable_channel_list_controller.js @@ -0,0 +1,23 @@ +import { Controller } from "@hotwired/stimulus" +import { Modal } from "bootstrap" + +export default class extends Controller { + static targets = [ + "filterQuery", + ]; + + filter() { + const channelList = document.querySelectorAll('[data-channel-link]'); + const filterQuery = this.filterQueryTarget.value.toUpperCase(); + + channelList.forEach((channelLink) => { + const channelName = channelLink.dataset.channelName; + + if (channelName.toUpperCase().indexOf(filterQuery) > -1) { + channelLink.style.display = ""; + } else { + channelLink.style.display = "none"; + } + }); + } +} diff --git a/app/javascript/controllers/load_more_controller.js b/app/javascript/controllers/load_more_controller.js new file mode 100644 index 0000000..8543bad --- /dev/null +++ b/app/javascript/controllers/load_more_controller.js @@ -0,0 +1,27 @@ +import { Controller } from "@hotwired/stimulus"; +import Rails from "@rails/ujs"; + +export default class extends Controller { + static values = { + page: Number, + url: String, + }; + + loadMoreMessage() { + this.pageValue++ + + fetch( + `${this.urlValue}?page=${this.pageValue}`, + { + method: "GET", + credentials: "same-origin", + headers: { "X-CSRF_Token": Rails.csrfToken() }, + format: "TURBO_STREAM" + } + ) + .then((response) => response.text()) + .then((html) => { + Turbo.renderStreamMessage(html); + }) + } +} diff --git a/app/javascript/controllers/navbar_controller.js b/app/javascript/controllers/navbar_controller.js deleted file mode 100644 index e34274d..0000000 --- a/app/javascript/controllers/navbar_controller.js +++ /dev/null @@ -1,28 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = [ - "chatLink", - "exploreLink", - "profileLink", - ]; - - connect() { - const pathname = window.location.pathname; - - switch(pathname) { - case "/profile": - this.profileLinkTarget.classList.add("active"); - break; - case "/chat": - this.chatLinkTarget.classList.add("active"); - break; - case "/search_locations": - this.exploreLinkTarget.classList.add("active"); - break; - case "/": - this.exploreLinkTarget.classList.add("active"); - break; - } - } -} diff --git a/app/javascript/controllers/navbar_link_controller.js b/app/javascript/controllers/navbar_link_controller.js new file mode 100644 index 0000000..e2fbc54 --- /dev/null +++ b/app/javascript/controllers/navbar_link_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ + "link", + ]; + static values = { + activePaths: Array, + } + + connect() { + const firstPath = window.location.pathname.split("/")[1]; + + if (this.activePathsValue.includes(firstPath)) { + this.linkTarget.classList.add("active"); + } + } +} diff --git a/app/lib/stream_chat_client.rb b/app/lib/stream_chat_client.rb deleted file mode 100644 index feea43c..0000000 --- a/app/lib/stream_chat_client.rb +++ /dev/null @@ -1,36 +0,0 @@ -require "stream-chat" - -class StreamChatClient - def self.add_member(channel:, user_id:) - channel.add_members([user_id]) - end - - def self.channel_include?(channel:, user_id:) - #channel.query_members( - # filter_conditions: { id: { "$in" => [user_id] } } - #)["members"].any? - - # TODO the filter_conditions above doesn't work - # this is much slower but for some reason query_members, - # regardless of user_id, always returns all users - members = channel.query_members()["members"] - members.map{ |member| member["user_id"] }.include?(user_id) - end - - def self.get_channel(type:, channel_id:) - client.channel(type, channel_id: channel_id) - end - - def self.create_stream_user(id:) - client.create_token(id) - end - - private - - def self.client - StreamChat::Client.new( - api_key=ENV["STREAM_API_KEY"], - api_secret=ENV["STREAM_API_SECRET"] - ) - end -end diff --git a/app/models/channel.rb b/app/models/channel.rb new file mode 100644 index 0000000..2660444 --- /dev/null +++ b/app/models/channel.rb @@ -0,0 +1,20 @@ +class Channel < ApplicationRecord + DEFAULT_CHAT_CHANNELS = [ + "General", + "Feedback and requests", + "Bugs" + ] + + validates :name, presence: true + + has_many :channel_members + has_many :messages, class_name: "ChannelMessage" + + def include?(user:) + channel_members.find_by(user_id: user.id).present? + end + + def latest_message + messages.order(:created_at).last + end +end diff --git a/app/models/channel_member.rb b/app/models/channel_member.rb new file mode 100644 index 0000000..b79cbe4 --- /dev/null +++ b/app/models/channel_member.rb @@ -0,0 +1,6 @@ +class ChannelMember < ApplicationRecord + belongs_to :chat_channel, + foreign_key: :channel_id, + class_name: "Channel" + belongs_to :user +end diff --git a/app/models/channel_message.rb b/app/models/channel_message.rb new file mode 100644 index 0000000..c929d14 --- /dev/null +++ b/app/models/channel_message.rb @@ -0,0 +1,16 @@ +class ChannelMessage < ApplicationRecord + belongs_to :channel + belongs_to :sender, + foreign_key: :user_id, + class_name: "User" + belongs_to :reply_to, + foreign_key: :reply_to_id, + class_name: "ChannelMessage", + optional: true + + validates :body, presence: true + + def body + deleted? ? "Deleted message" : self[:body] + end +end diff --git a/app/models/profile_picture.rb b/app/models/profile_picture.rb new file mode 100644 index 0000000..9558af2 --- /dev/null +++ b/app/models/profile_picture.rb @@ -0,0 +1,7 @@ +class ProfilePicture < ApplicationRecord + include ProfilePictureUploader::Attachment(:image) + + belongs_to :user + + validates :image, presence: true +end diff --git a/app/models/user.rb b/app/models/user.rb index 27c5d4d..e9daf22 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -9,6 +9,10 @@ class User < ApplicationRecord :trackable, :validatable + has_many :channel_members + has_many :chat_channels, + through: :channel_members + has_one :profile_picture has_many :reviews validates :display_name, @@ -37,4 +41,22 @@ def no_subscription? !self.admin? && self[:subscription_status].nil? end + + def unread_channel_count + channel_members + .joins(:chat_channel) + .where("channel_members.last_active < channels.last_action_at") + .count + end + + def unread_message_count(channel:) + member = channel.channel_members.find_by(user_id: self[:id]) + + return 0 if member.nil? + + channel + .messages + .where("created_at > ?", member.last_active) + .count + end end diff --git a/app/policies/channel_member_policy.rb b/app/policies/channel_member_policy.rb new file mode 100644 index 0000000..16269d9 --- /dev/null +++ b/app/policies/channel_member_policy.rb @@ -0,0 +1,9 @@ +class ChannelMemberPolicy < ApplicationPolicy + def destroy? + record.user == user + end + + def update_last_active? + record.user == user + end +end diff --git a/app/policies/channel_message_policy.rb b/app/policies/channel_message_policy.rb new file mode 100644 index 0000000..c3b99f5 --- /dev/null +++ b/app/policies/channel_message_policy.rb @@ -0,0 +1,10 @@ +class ChannelMessagePolicy < ApplicationPolicy + def create? + record.channel.channel_members.find_by(user_id: user.id).present? && + record.sender == user + end + + def destroy? + user.admin? || create? + end +end diff --git a/app/policies/channel_policy.rb b/app/policies/channel_policy.rb new file mode 100644 index 0000000..f9fa07f --- /dev/null +++ b/app/policies/channel_policy.rb @@ -0,0 +1,13 @@ +class ChannelPolicy < ApplicationPolicy + def show? + user.admin? || user.active_subscription? + end + + def new? + user.admin? + end + + def create? + new? + end +end diff --git a/app/uploaders/profile_picture_uploader.rb b/app/uploaders/profile_picture_uploader.rb new file mode 100644 index 0000000..58fa09d --- /dev/null +++ b/app/uploaders/profile_picture_uploader.rb @@ -0,0 +1,13 @@ +require "image_processing/mini_magick" + +class ProfilePictureUploader < Shrine + Attacher.derivatives do |original| + magick = ImageProcessing::MiniMagick.source(original) + + { + # 3x usage size + chat: magick.resize_to_fill!(108, 108), + main: magick.resize_to_fill!(540, 540), + } + end +end diff --git a/app/views/channel_messages/create.turbo_stream.erb b/app/views/channel_messages/create.turbo_stream.erb new file mode 100644 index 0000000..cc408ce --- /dev/null +++ b/app/views/channel_messages/create.turbo_stream.erb @@ -0,0 +1,11 @@ +<%= turbo_stream.replace( + "chat-channel", + partial: "channels/chat_message_section", + locals: { channel: @channel, message: ChannelMessage.new } +) %> + +<%= turbo_stream.replace( + "channel-list", + partial: "chats/current_user_channel_list", + locals: { user: current_user } +) %> diff --git a/app/views/channels/_chat_message.html.erb b/app/views/channels/_chat_message.html.erb new file mode 100644 index 0000000..549714e --- /dev/null +++ b/app/views/channels/_chat_message.html.erb @@ -0,0 +1,71 @@ +<%= turbo_frame_tag "chat-message-#{message.id}" do %> + <% if message.sender != user %> + + <%= message.sender.display_name %> + + <% end %> + +
+ <% if message.sender != user %> + <% if message.sender.profile_picture.present? %> +
+ <%= image_tag( + message.sender.profile_picture.image_url(:chat), + class: "profile-picture chat-profile-picture" + ) %> +
+ <% else %> +
+ <%= helper_user_initials(user: current_user) %> +
+ <% end %> + <% end %> + +

> + <% if message.reply_to.present? %> + <%= link_to( + "javascript:void(0);", + data: { + action: "chat-channel#jumpToReply", + "reply-to-id": message.reply_to.id + } + ) do %> + + + Replying to <%= message.reply_to.sender.display_name %> +
+ <%= helper_short_message(message: message.reply_to.body) %> +
+ <% end %> +
+ <% end %> + <%= message.body %> +

+ +
+ <% if message.sender == user || user.admin? %> + <%= button_to( + channel_channel_message_path( + message, + channel_id: channel.id + ), + method: :delete, + class: "delete-button me-2", + aria: { label: "Delete message" }, + form: { + data: { + turbo_confirm: "Are you sure you want to delete this message? This action can't be undone." + } + } + ) do %> + + <% end %> + <% end %> + + <%= render partial: "channels/shared/reply_to_link", + locals: { message: message } + %> +
+
+<% end %> diff --git a/app/views/channels/_chat_message_section.html.erb b/app/views/channels/_chat_message_section.html.erb new file mode 100644 index 0000000..737af76 --- /dev/null +++ b/app/views/channels/_chat_message_section.html.erb @@ -0,0 +1,178 @@ +<%= turbo_stream_from "user-#{current_user.id}-channel-#{channel.id}" %> + +<%= turbo_frame_tag( + "chat-channel", + class: "chat-channel-section", + data: { + controller: "chat-channel", + "chat-channel-channel-member-id-value": channel.include?(user: current_user) ? ( + ChannelMember.find_by( + user: current_user, + chat_channel: channel + ).id + ) : nil + } +) do %> + +
+

<%= channel.name %>

+ + <% if channel.include?(user: current_user) %> + + <% end %> +
+ +
+ <%= turbo_frame_tag "channel-messages" do %> + <%= render partial: "channels/chat_messages", + locals: { + messages: @messages, + channel: channel, + user: current_user + } + %> + <% end %> + + <% if @messages.length >= 50 %> + <%= turbo_frame_tag "load-more-link" do %> + + <% end %> + <% else %> + <%= render partial: "channels/start_of_conversation" %> + <% end %> +
+ +
+

+ Couldn't scroll to message - it may be offscreen +

+ + <% if channel.include?(user: current_user) %> +
+

+ Replying to + + +
+ +

+ <%= link_to( + "javascript:void(0)", + data: { action: "chat-channel#clearReplyTo" } + ) do %> + + <% end %> +
+ <%= form_for [channel, message] do |f| %> + <%= f.hidden_field( + :reply_to_id, + value: nil, + data: { + "chat-channel-target": "hiddenReplyToField" + } + ) %> + +
+
+ <%= f.text_area( + :body, + class: "form-control#{ message.errors[:body].any? ? ' is-invalid' : nil }", + rows: 1, + placeholder: "Type message...", + autofocus: true, + data: { + "chat-channel-target": "messageTextarea", + "chat-textarea-target": "textarea", + action: "chat-textarea#resizeTextarea" + } + ) %> + + <% if message.errors[:body].present? %> +
+ Message body <%= message.errors[:body].first %> +
+ <% end %> +
+ +
+ <%= f.button( + "Send", + type: :submit, + class: "btn btn-success ms-2" + ) do %> + + Send + <% end %> +
+
+ <% end %> + <% else %> +
+ <% if flash[:error_join_channel] %> + + <% end %> + + <%= form_for( + ChannelMember.new, + data: { + turbo_frame: "_top" + } + ) do |f| %> + <%= f.hidden_field :channel_id, value: channel.id %> + <%= f.hidden_field :user_id, value: current_user.id %> + + <%= f.submit "Join channel", + class: "btn btn-success btn-lg" + %> + <% end %> +
+ <% end %> +
+<% end %> diff --git a/app/views/channels/_chat_messages.html.erb b/app/views/channels/_chat_messages.html.erb new file mode 100644 index 0000000..2866e23 --- /dev/null +++ b/app/views/channels/_chat_messages.html.erb @@ -0,0 +1,9 @@ +<% messages.reverse_each do |message| %> + <%= render partial: "channels/chat_message", + locals: { + message: message, + channel: channel, + user: current_user + } + %> +<% end %> diff --git a/app/views/channels/_start_of_conversation.html.erb b/app/views/channels/_start_of_conversation.html.erb new file mode 100644 index 0000000..efe371f --- /dev/null +++ b/app/views/channels/_start_of_conversation.html.erb @@ -0,0 +1,3 @@ +

+ Start of conversation +

diff --git a/app/views/channels/new.html.erb b/app/views/channels/new.html.erb new file mode 100644 index 0000000..0886bb1 --- /dev/null +++ b/app/views/channels/new.html.erb @@ -0,0 +1,29 @@ +
+

Adding a new channel

+ + <% if flash[:error_create_channel].present? %> + + <% end %> + + <%= form_for @channel, data: { turbo_frame: "_top" } do |f| %> +
+ <%= f.label :name, "Channel name" %> + <%= f.text_field :name, + class: "form-control#{ @channel.errors[:name].any? ? ' is-invalid' : nil}" + %> + + <% if @channel.errors[:name].present? %> +
+ Channel name <%= @channel.errors[:name].first %> +
+ <% end %> +
+ + <%= f.submit "Add channel", + class: "btn btn-success" + %> + <% end %> +
diff --git a/app/views/channels/shared/_reply_to_link.html.erb b/app/views/channels/shared/_reply_to_link.html.erb new file mode 100644 index 0000000..cfc4352 --- /dev/null +++ b/app/views/channels/shared/_reply_to_link.html.erb @@ -0,0 +1,12 @@ +<%= link_to( + "javascript:void(0);", + aria: { label: "Reply to message" }, + data: { + action: "chat-channel#setReplyTo", + "message-id": message.id, + "message-body": message.body, + "message-sender": message.sender.display_name + } +) do %> + +<% end %> diff --git a/app/views/chats/_current_user_channel_list.html.erb b/app/views/chats/_current_user_channel_list.html.erb new file mode 100644 index 0000000..8fdbde2 --- /dev/null +++ b/app/views/chats/_current_user_channel_list.html.erb @@ -0,0 +1,36 @@ +<%= turbo_frame_tag "channel-list", class: "channel-list-container col pe-0" do %> +
+ <%= render partial: "chats/shared/channel_list", + locals: { + channels: user.chat_channels, + user: user + } + %> + +
+ <%= link_to( + joinable_channels_path, + class: "btn btn-primary m-2", + data: { + turbo_stream: true + } + ) do %> + + Join channel + <% end %> + + <% if user.admin? %> + <%= link_to( + new_channel_path, + class: "btn btn-primary mx-2 mb-2", + data: { + turbo_frame: "_top" + } + ) do %> + + Add channel + <% end %> + <% end %> +
+
+<% end %> diff --git a/app/views/chats/_disconnected_modal.html.erb b/app/views/chats/_disconnected_modal.html.erb new file mode 100644 index 0000000..d57d564 --- /dev/null +++ b/app/views/chats/_disconnected_modal.html.erb @@ -0,0 +1,26 @@ + diff --git a/app/views/chats/_joinable_channel_list.html.erb b/app/views/chats/_joinable_channel_list.html.erb new file mode 100644 index 0000000..cf3c9e3 --- /dev/null +++ b/app/views/chats/_joinable_channel_list.html.erb @@ -0,0 +1,52 @@ +<%= turbo_frame_tag( + "joinable-channel-list", + class: "channel-list-container col pe-0", + data: { + controller: "joinable-channel-list" + } +) do %> +
+
+ <%= text_field( + "", + :filter, + class: "form-control", + placeholder: "Search channels...", + data: { + "joinable-channel-list-target": "filterQuery", + action: "joinable-channel-list#filter", + } + ) %> + + + +
+
+ +
+ <% if channels.any? %> + <%= render partial: "chats/shared/channel_list", + locals: { user: current_user, channels: channels } + %> + <% else %> + + <% end %> + +
+ <%= link_to( + current_user_list_channels_path, + class: "btn btn-primary m-2", + data: { + turbo_stream: true + } + ) do %> + + Back to your channels + <% end %> +
+
+<% end %> diff --git a/app/views/chats/shared/_channel_list.html.erb b/app/views/chats/shared/_channel_list.html.erb new file mode 100644 index 0000000..48a3ab0 --- /dev/null +++ b/app/views/chats/shared/_channel_list.html.erb @@ -0,0 +1,27 @@ + diff --git a/app/views/chats/show.html.erb b/app/views/chats/show.html.erb index de2496e..10c6935 100644 --- a/app/views/chats/show.html.erb +++ b/app/views/chats/show.html.erb @@ -17,25 +17,30 @@ <%= render partial: "layouts/navbar" %> + <%= render partial: "chats/disconnected_modal" %> - <% if @stream_env_vars_missing %> -
-

- - ENV["STREAM_API_KEY"] and/or ENV["STREAM_API_SECRET"] not set. - -
- Please check your environment configuration and try again. -

-
- <% else %> -
- <% end %> + <%= turbo_stream_from "user-#{current_user.id}-chat" %> + +
+ <%= render partial: "chats/current_user_channel_list", + locals: { + user: current_user + } + %> + + <% if @channel.present? %> +
+ <%= render partial: "channels/chat_message_section", + locals: { channel: @channel, message: @message } + %> +
+ <% else %> +
+

+ Click on one of channels on the left to start chatting +

+
+ <% end %> +
diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index 8d5b0bb..8071f1c 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -1,4 +1,4 @@ -
+
<% if flash[:notice].present? %>