diff --git a/Gemfile b/Gemfile index 5e2568540..b080fc5e7 100644 --- a/Gemfile +++ b/Gemfile @@ -15,6 +15,7 @@ gem "cable_ready", "~> 4.1.0" gem "camo", "~> 0.1.0" gem "chroma", "~> 0.2.0" gem "chronic", "~> 0.10.2" +gem "closure_tree", "~> 7.1" gem "cloudflare-rails", "~> 0.6.0", group: :production gem "consolidated_screening_list", "~> 0.0.2" gem "countries", "~> 3.0.0" diff --git a/Gemfile.lock b/Gemfile.lock index 7bec289c3..56220906a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -135,6 +135,9 @@ GEM chronic (0.10.2) chunky_png (1.3.11) cliver (0.3.2) + closure_tree (7.1.0) + activerecord (>= 4.2.10) + with_advisory_lock (>= 4.0.0) cloudflare-rails (0.6.0) httparty rails (>= 5.0, < 6.1.0) @@ -590,6 +593,8 @@ GEM websocket-driver (0.7.1) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.4) + with_advisory_lock (4.6.0) + activerecord (>= 4.2) xpath (3.2.0) nokogiri (~> 1.8) yard (0.9.25) @@ -614,6 +619,7 @@ DEPENDENCIES capybara (>= 2.15) chroma (~> 0.2.0) chronic (~> 0.10.2) + closure_tree (~> 7.1) cloudflare-rails (~> 0.6.0) codecov consolidated_screening_list (~> 0.0.2) diff --git a/app/components/page_component.html.erb b/app/components/page_component.html.erb index e01df99f7..b0e3c1b9d 100644 --- a/app/components/page_component.html.erb +++ b/app/components/page_component.html.erb @@ -1,10 +1,10 @@ -
"> +<%= content_tag("div", class: classes) do %> <%= tag.div(class: "sidebar-backdrop") if sidebar %>
<%= header %> <%= render(tabs) if tabs %> - <%= tag.hr(class: "my-3") if !tabs %> + <%= tag.hr(class: "my-3") if !tabs && header %>
<%= body %>
<%= render "/#{subject_view_directory}/sidebar", subject: subject if sidebar %>
-
+<% end %> diff --git a/app/components/page_component.rb b/app/components/page_component.rb index 155f78efe..5ba7b51d1 100644 --- a/app/components/page_component.rb +++ b/app/components/page_component.rb @@ -1,10 +1,11 @@ class PageComponent < ApplicationComponent with_content_areas :header, :body - def initialize(subject: nil, tabs: false, sidebar: false) + def initialize(subject: nil, tabs: false, sidebar: false, classes: nil) @subject = subject @tabs = tabs @sidebar = sidebar + @class_names = classes end def subject_view_directory @@ -14,5 +15,12 @@ def subject_view_directory private - attr_reader :subject, :sidebar, :tabs + attr_reader :subject, :sidebar, :tabs, :class_names + + def classes + classes = ["page"] + classes << "has-sidebar has-sidebar-expand-xl" if sidebar + classes << class_names if class_names + classes.compact + end end diff --git a/app/components/users/avatar_component.html.erb b/app/components/users/avatar_component.html.erb new file mode 100644 index 000000000..e69de29bb diff --git a/app/controllers/emails_controller.rb b/app/controllers/emails_controller.rb index 117714ed7..813d8fcf6 100644 --- a/app/controllers/emails_controller.rb +++ b/app/controllers/emails_controller.rb @@ -1,26 +1,32 @@ class EmailsController < ApplicationController before_action :authenticate_user! before_action :authorize_view! - before_action :set_user before_action :set_email, only: :show def index - emails = @user.emails.order(delivered_at: :desc) + emails = current_user.emails + @current_filter = params[:filter] || "All" + session[:email_date_format] ||= "default" + + emails = + case @current_filter + when "Unread" then emails.inbound.unread_by(current_user) + when "Sent" then emails.outbound + else emails.inbound + end + + emails = emails.order(delivered_at: :desc) @pagy, @emails = pagy(emails) end - private - - def set_user - @user = if authorized_user.can_admin_system? - User.find(params[:user_id]) - else - current_user - end + def show + @email.mark_read_for_user!(current_user) end + private + def set_email - @email = @user.emails.find(params[:id]) + @email = current_user.emails.find(params[:id]) end def authorize_view! diff --git a/app/javascript/themes/current/stylesheets/components.scss b/app/javascript/themes/current/stylesheets/components.scss index 9408b9b09..2ca3dfe78 100644 --- a/app/javascript/themes/current/stylesheets/components.scss +++ b/app/javascript/themes/current/stylesheets/components.scss @@ -2,9 +2,10 @@ @import './components/checkbox_tree'; @import './components/creative_preview'; @import './components/dropdown'; +@import './components/emails'; @import './components/navigation'; +@import './components/page'; @import './components/select2'; @import './components/sparkline'; @import './components/tables'; @import './components/trix'; -@import './components/emails'; diff --git a/app/javascript/themes/current/stylesheets/components/page.scss b/app/javascript/themes/current/stylesheets/components/page.scss new file mode 100644 index 000000000..00b2e6509 --- /dev/null +++ b/app/javascript/themes/current/stylesheets/components/page.scss @@ -0,0 +1,29 @@ +.page-fixed { + display: flex; + flex-direction: column; + height: 100%; + + .page-fixed-header { + position: relative; + padding: 0.5rem 0.5rem 0.5rem 0.25rem; + display: flex; + align-items: center; + height: 3.5rem; + background-color: #fff; + box-shadow: 0 1px 0 0 rgba(20, 20, 31, 0.075); + z-index: 5; + border-bottom: 1px solid #ecedf1; + } + .page-fixed-body { + padding: 0rem; + flex: 1; + overflow-y: auto; + } + .page-fixed-footer { + position: relative; + padding: 0.5rem; + background-color: #fff; + box-shadow: 0 -1px 0 0 rgba(20, 20, 31, 0.075); + z-index: 1; + } +} diff --git a/app/mailboxes/incoming_mailbox.rb b/app/mailboxes/incoming_mailbox.rb index 601bcb4ee..7e4dadd52 100644 --- a/app/mailboxes/incoming_mailbox.rb +++ b/app/mailboxes/incoming_mailbox.rb @@ -11,10 +11,15 @@ def process inbound_email.created_at end + parent_email_id = Email.find_by(message_id: mail.in_reply_to)&.id + email = Email.create! \ action_mailbox_inbound_email_id: inbound_email.id, sender: mail.from.first, recipients: (mail.to.to_a + mail.cc.to_a).uniq.compact.sort, + message_id: mail.message_id, + parent_id: parent_email_id, + in_reply_to: mail.in_reply_to, subject: mail.subject, snippet: snippet, body: body, diff --git a/app/models/concerns/emails/presentable.rb b/app/models/concerns/emails/presentable.rb new file mode 100644 index 000000000..c17a9c140 --- /dev/null +++ b/app/models/concerns/emails/presentable.rb @@ -0,0 +1,11 @@ +module Emails + module Presentable + extend ActiveSupport::Concern + include ActionView::Helpers::DateHelper + + def human_delivered_at + from_time = Time.now + distance_of_time_in_words(from_time, delivered_at, scope: "datetime.distance_in_words.email") # => "2H" + end + end +end diff --git a/app/models/concerns/users/presentable.rb b/app/models/concerns/users/presentable.rb index 2a2b121cc..f387e4153 100644 --- a/app/models/concerns/users/presentable.rb +++ b/app/models/concerns/users/presentable.rb @@ -20,8 +20,8 @@ def hashed_email Digest::MD5.hexdigest(email.downcase) end - def gravatar_url(d = "identicon") - "https://www.gravatar.com/avatar/#{hashed_email}?s=300&d=#{d}" + def gravatar_url(d = "identicon", s = "300") + "https://www.gravatar.com/avatar/#{hashed_email}?s=#{s}&d=#{d}" end def display_region diff --git a/app/models/email.rb b/app/models/email.rb index 942d5748b..27b66665d 100644 --- a/app/models/email.rb +++ b/app/models/email.rb @@ -7,6 +7,7 @@ # delivered_at :datetime not null # delivered_at_date :date not null # direction :string default("inbound"), not null +# in_reply_to :string # recipients :string default([]), not null, is an Array # sender :string # snippet :text @@ -14,17 +15,22 @@ # created_at :datetime not null # updated_at :datetime not null # action_mailbox_inbound_email_id :bigint not null +# message_id :string +# parent_id :bigint # # Indexes # # index_emails_on_delivered_at_date (delivered_at_date) # index_emails_on_delivered_at_hour (date_trunc('hour'::text, delivered_at)) +# index_emails_on_parent_id (parent_id) # index_emails_on_recipients (recipients) USING gin # index_emails_on_sender (sender) # class Email < ApplicationRecord # extends ................................................................... + # includes .................................................................. + include Emails::Presentable # relationships ............................................................. has_many :email_users @@ -35,17 +41,33 @@ class Email < ApplicationRecord validates :recipients, presence: true validates :sender, presence: true validates :direction, presence: true, inclusion: {in: ENUMS::EMAIL_DIRECTIONS.values} + validates :message_id, presence: true, uniqueness: true # callbacks ................................................................. + # scopes .................................................................... + scope :inbound, -> { where(direction: ENUMS::EMAIL_DIRECTIONS::INBOUND) } + scope :outbound, -> { where(direction: ENUMS::EMAIL_DIRECTIONS::OUTBOUND) } + scope :unread_by, ->(user) { where(email_users: {user: user, read_at: nil}) } + scope :read_by, ->(user) { where(email_users: {user: user}).where.not(email_users: {read_at: nil}) } # additional config (i.e. accepts_nested_attribute_for etc...) .............. + has_closure_tree # see https://github.com/ClosureTree/closure_tree#accessing-data has_rich_text :body has_many_attached :attachments # class methods ............................................................. # public instance methods ................................................... + + def inbound? + direction == ENUMS::EMAIL_DIRECTIONS::INBOUND + end + + def outbound? + direction == ENUMS::EMAIL_DIRECTIONS::OUTBOUND + end + def participant_addresses [sender, recipients].flatten.compact.sort end @@ -55,7 +77,33 @@ def participant_users end def sending_user - @sender ||= User.find_by(email: sender) + @sending_user ||= User.find_by(email: sender) + end + + def non_admin_users + participant_users.non_administrators + end + + def participating_organizations + non_admin_users.map(&:default_organization).compact.sort + end + + def inbound_email + ActionMailbox::InboundEmail.find_by(id: action_mailbox_inbound_email_id) + end + + def mark_read_for_user!(user) + return unless user + email_users.find_by(user: user).update!(read_at: Time.current) + end + + def mark_unread_for_user!(user) + return unless user + email_users.find_by(user: user).update!(read_at: nil) + end + + def read_by?(user) + email_users.find_by(user: user).read_at end # protected instance methods ................................................ diff --git a/app/models/email_user.rb b/app/models/email_user.rb index a9eead813..7afaf859f 100644 --- a/app/models/email_user.rb +++ b/app/models/email_user.rb @@ -3,6 +3,7 @@ # Table name: email_users # # id :bigint not null, primary key +# read_at :datetime # email_id :bigint not null # user_id :bigint not null # diff --git a/app/models/user.rb b/app/models/user.rb index 61eceab03..2515010cf 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -224,7 +224,7 @@ def codefund_bot def referral_code(user_id) where(id: user_id).limit(1).pluck(:referral_code).first end - end + end # public instance methods ................................................... diff --git a/app/reflexes/emails_reflex.rb b/app/reflexes/emails_reflex.rb new file mode 100644 index 000000000..633064174 --- /dev/null +++ b/app/reflexes/emails_reflex.rb @@ -0,0 +1,19 @@ +class EmailsReflex < ApplicationReflex + def mark_read + email.mark_read_for_user! current_user + end + + def mark_unread + email.mark_unread_for_user! current_user + end + + def toggle_date_format + session[:email_date_format] = element.dataset["date-format"] == "default" ? "human" : "default" + end + + private + + def email + @email ||= Email.find_by(id: element.dataset["email-id"]) + end +end diff --git a/app/views/emails/index.html.erb b/app/views/emails/index.html.erb index e741919ca..69b081127 100644 --- a/app/views/emails/index.html.erb +++ b/app/views/emails/index.html.erb @@ -1,2 +1,73 @@ -

Emails#index

-

Find me in app/views/emails/index.html.erb

+
+
+ +
+ <%= pagination_wrapper do %> + <%== pagy_bootstrap_nav(@pagy) %> + <%= pagy_entries(@pagy) %> + <% end if @pagy.pages > 1 %> +
+
+ + + <% @emails.each do |email| %> + + + + + + + <% end %> + +
+ <% if !email.read_by?(current_user) && email.inbound? %> + + <% else %> + + <% end %> + + <% if sending_user = email.sending_user %> + <%= link_to sending_user.name, sending_user, class: "text-dark" %> + <% if org = email.participating_organizations.first %> +
+ <%= link_to org.name, org, class: "text-muted font-weight-light" %> + <% end %> + <% else %> + <%= email.sender %> + <% end %> +
+ <%= link_to email_path(email), class: "text-decoration-none" do %> + + <%= tag.i class: "fas fa-download text-muted" if email.attachments.exists? %> + <%= email.subject %> + + <%= email.snippet.html_safe %> + <% end %> + + + <% if session[:email_date_format] == "human" %> + <%= email.human_delivered_at %> + <% else %> + <%= email.delivered_at.to_s("bdy") %> +
+ <%= email.delivered_at.localtime.to_s("time") %> + <% end %> +
+
+
+
+ <%= pagination_wrapper do %> + <%== pagy_bootstrap_nav(@pagy) %> + <%= pagy_entries(@pagy) %> + <% end if @pagy.pages > 1 %> +
+
+
diff --git a/app/views/emails/show.html.erb b/app/views/emails/show.html.erb index a1c2130f3..d9a9b1b90 100644 --- a/app/views/emails/show.html.erb +++ b/app/views/emails/show.html.erb @@ -1,2 +1,6 @@ -

Emails#show

-

Find me in app/views/emails/show.html.erb

+<%= render(PageComponent.new) do |component| %> + <% component.with(:body) do %> + <%= render BackLinkComponent.new(title: "Emails", link: emails_path) %> + <%= render EmailComponent.new(email: @email) %> + <% end %> +<% end %> diff --git a/app/views/shared/_sidebar.html.erb b/app/views/shared/_sidebar.html.erb index 5ec9e1c48..d45a99339 100644 --- a/app/views/shared/_sidebar.html.erb +++ b/app/views/shared/_sidebar.html.erb @@ -34,7 +34,7 @@