Project

General

Profile

Feature #42630 » 0001-Adds-reaction-feature-to-issues-news-and-forums.patch

Katsuya HIDAKA, 2025-04-25 08:40

View differences:

app/assets/images/icons.svg
459 459
      <path d="M19 15v6h3"/>
460 460
      <path d="M11 21v-6l2.5 3l2.5 -3v6"/>
461 461
    </symbol>
462
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--thumb-up">
463
      <path d="M7 11v8a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-7a1 1 0 0 1 1 -1h3a4 4 0 0 0 4 -4v-1a2 2 0 0 1 4 0v5h3a2 2 0 0 1 2 2l-1 5a2 3 0 0 1 -2 2h-7a3 3 0 0 1 -3 -3"/>
464
    </symbol>
465
    <symbol viewBox="0 0 24 24" id="icon--thumb-up-filled">
466
      <path d="M13 3a3 3 0 0 1 2.995 2.824l.005 .176v4h2a3 3 0 0 1 2.98 2.65l.015 .174l.005 .176l-.02 .196l-1.006 5.032c-.381 1.626 -1.502 2.796 -2.81 2.78l-.164 -.008h-8a1 1 0 0 1 -.993 -.883l-.007 -.117l.001 -9.536a1 1 0 0 1 .5 -.865a2.998 2.998 0 0 0 1.492 -2.397l.007 -.202v-1a3 3 0 0 1 3 -3z"/>
467
      <path d="M5 10a1 1 0 0 1 .993 .883l.007 .117v9a1 1 0 0 1 -.883 .993l-.117 .007h-1a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-7a2 2 0 0 1 1.85 -1.995l.15 -.005h1z"/>
468
    </symbol>
462 469
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--time">
463 470
      <path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/>
464 471
      <path d="M12 7v5l3 3"/>
app/assets/javascripts/application-legacy.js
1222 1222
  });
1223 1223
}
1224 1224

  
1225
function setupHoverTooltips() {
1226
  $("[title]:not(.no-tooltip)").tooltip({
1225
function setupHoverTooltips(container) {
1226
  $(container || 'body').find("[title]:not(.no-tooltip)").tooltip({
1227 1227
    show: {
1228 1228
      delay: 400
1229 1229
    },
......
1233 1233
    }
1234 1234
  });
1235 1235
}
1236

  
1236
function removeHoverTooltips(container) {
1237
  $(container || 'body').find("[title]:not(.no-tooltip)").tooltip('destroy')
1238
}
1237 1239
$(function() { setupHoverTooltips(); });
1238 1240

  
1239 1241
function inlineAutoComplete(element) {
app/assets/stylesheets/application.css
2052 2052

  
2053 2053
img.filecontent.image {background-image: url(/transparent.png);}
2054 2054

  
2055
/* Reaction styles */
2056
.reaction-button.reacted .icon-svg {
2057
  fill: #126fa7;
2058
  stroke: none;
2059
}
2060
.reaction-button.reacted:hover .icon-svg {
2061
  fill: #c61a1a;
2062
}
2063
.reaction-button .icon-label {
2064
  margin-left: 3px;
2065
  margin-bottom: -1px;
2066
}
2067
div.issue.details .reaction {
2068
  float: right;
2069
  font-size: 0.9em;
2070
  margin-top: 0.5em;
2071
}
2072
div.message .reaction {
2073
  float: right;
2074
  font-size: 0.9em;
2075
}
2076
div.news .reaction {
2077
  float: right;
2078
  font-size: 0.9em;
2079
}
2080

  
2055 2081
/* Custom JQuery styles */
2056 2082
.ui-autocomplete, .ui-menu {
2057 2083
  border-radius: 2px;
app/controllers/messages_controller.rb
49 49
      reorder("#{Message.table_name}.created_on ASC, #{Message.table_name}.id ASC").
50 50
      limit(@reply_pages.per_page).
51 51
      offset(@reply_pages.offset).
52
      to_a
52
      load_with_reactions
53 53

  
54 54
    @reply = Message.new(:subject => "RE: #{@message.subject}")
55 55
    render :action => "show", :layout => false if request.xhr?
app/controllers/news_controller.rb
67 67
  end
68 68

  
69 69
  def show
70
    @comments = @news.comments.to_a
70
    @comments = @news.comments.load_with_reactions
71 71
    @comments.reverse! if User.current.wants_comments_in_reverse_order?
72 72
  end
73 73

  
app/controllers/reactions_controller.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
class ReactionsController < ApplicationController
21
  before_action :check_enabled
22

  
23
  before_action :require_login
24
  before_action :set_object, :authorize_reactable
25

  
26
  def create
27
    @reaction = @object.reactions.find_or_create_by!(user: User.current)
28
  end
29

  
30
  def destroy
31
    @reaction = @object.reactions.by(User.current).find_by(id: params[:id])
32
    @reaction&.destroy
33
  end
34

  
35
  private
36

  
37
  def check_enabled
38
    render_403 unless Setting.reactions_enabled?
39
  end
40

  
41
  def set_object
42
    object_type = params[:object_type]
43

  
44
    unless Redmine::Reaction::REACTABLE_TYPES.include?(object_type)
45
      render_403
46
      return
47
    end
48

  
49
    @object = object_type.constantize.find(params[:object_id])
50
  end
51

  
52
  def authorize_reactable
53
    render_403 unless @object.visible?(User.current)
54
  end
55
end
app/helpers/issues_helper.rb
22 22
  include Redmine::Export::PDF::IssuesPdfHelper
23 23
  include IssueStatusesHelper
24 24
  include QueriesHelper
25
  include ReactionsHelper
25 26

  
26 27
  def issue_list(issues, &)
27 28
    ancestors = []
app/helpers/journals_helper.rb
19 19

  
20 20
module JournalsHelper
21 21
  include Redmine::QuoteReply::Helper
22
  include ReactionsHelper
22 23

  
23 24
  # Returns the attachments of a journal that are displayed as thumbnails
24 25
  def journal_thumbnail_attachments(journal)
......
41 42
    end
42 43

  
43 44
    if journal.notes.present?
45
      links << reaction_button(journal)
46

  
44 47
      if options[:reply_links]
45 48
        url = quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice)
46 49
        links << quote_reply(url, "#journal-#{journal.id}-notes", icon_only: true)
app/helpers/messages_helper.rb
19 19

  
20 20
module MessagesHelper
21 21
  include Redmine::QuoteReply::Helper
22
  include ReactionsHelper
22 23
end
app/helpers/news_helper.rb
18 18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19 19

  
20 20
module NewsHelper
21
  include ReactionsHelper
21 22
end
app/helpers/reactions_helper.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module ReactionsHelper
21
  def reaction_button(object, reaction = nil)
22
    return unless Setting.reactions_enabled? && object.visible?(User.current)
23

  
24
    reaction ||= object.reaction_by(User.current)
25

  
26
    count = object.reaction_count
27
    user_names = object.reaction_user_names
28

  
29
    tooltip = build_reaction_tooltip(user_names, count)
30

  
31
    if User.current.logged?
32
      if reaction&.persisted?
33
        reaction_button_reacted(object, reaction, count, tooltip)
34
      else
35
        reaction_button_not_reacted(object, count, tooltip)
36
      end
37
    else
38
      reaction_button_readonly(object, count, tooltip)
39
    end
40
  end
41

  
42
  def reaction_id_for(object)
43
    dom_id(object, :reaction)
44
  end
45

  
46
  private
47

  
48
  def reaction_button_reacted(object, reaction, count, tooltip)
49
    reaction_button_wrapper object do
50
      link_to(
51
        sprite_icon('thumb-up-filled', count),
52
        reaction_path(reaction, object_type: object.class.name, object_id: object),
53
        remote: true, method: :delete,
54
        class: ['icon', 'reaction-button', 'reacted'],
55
        title: tooltip
56
      )
57
    end
58
  end
59

  
60
  def reaction_button_not_reacted(object, count, tooltip)
61
    reaction_button_wrapper object do
62
      link_to(
63
        sprite_icon('thumb-up', count),
64
        reactions_path(object_type: object.class.name, object_id: object),
65
        remote: true, method: :post,
66
        class: 'icon reaction-button',
67
        title: tooltip
68
      )
69
    end
70
  end
71

  
72
  def reaction_button_readonly(object, count, tooltip)
73
    reaction_button_wrapper object do
74
      tag.span(class: 'icon reaction-button', title: tooltip) do
75
        sprite_icon('thumb-up', count)
76
      end
77
    end
78
  end
79

  
80
  def reaction_button_wrapper(object, &)
81
    tag.span(data: { 'reaction-button-id': reaction_id_for(object) }, &)
82
  end
83

  
84
  def build_reaction_tooltip(user_names, count)
85
    return if count.zero?
86

  
87
    display_user_names = user_names.dup
88

  
89
    if count > Redmine::Reaction::DISPLAY_REACTION_USERS_LIMIT
90
      others = count - Redmine::Reaction::DISPLAY_REACTION_USERS_LIMIT
91
      display_user_names << I18n.t(:reaction_text_x_other_users, count: others)
92
    end
93

  
94
    display_user_names.to_sentence(locale: I18n.locale)
95
  end
96
end
app/models/comment.rb
19 19

  
20 20
class Comment < ApplicationRecord
21 21
  include Redmine::SafeAttributes
22
  include Redmine::Reaction::Reactable
23

  
22 24
  belongs_to :commented, :polymorphic => true, :counter_cache => true
23 25
  belongs_to :author, :class_name => 'User'
24 26

  
......
28 30

  
29 31
  safe_attributes 'comments'
30 32

  
33
  delegate :visible?, to: :commented
34

  
31 35
  def comments=(arg)
32 36
    self.content = arg
33 37
  end
app/models/issue.rb
25 25
  before_validation :clear_disabled_fields
26 26
  before_save :set_parent_id
27 27
  include Redmine::NestedSet::IssueNestedSet
28
  include Redmine::Reaction::Reactable
28 29

  
29 30
  belongs_to :project
30 31
  belongs_to :tracker
......
916 917
    result = journals.
917 918
      preload(:details).
918 919
      preload(:user => :email_address).
919
      reorder(:created_on, :id).to_a
920
      reorder(:created_on, :id).
921
      load_with_reactions
920 922

  
921 923
    result.each_with_index {|j, i| j.indice = i + 1}
922 924

  
app/models/journal.rb
19 19

  
20 20
class Journal < ApplicationRecord
21 21
  include Redmine::SafeAttributes
22
  include Redmine::Reaction::Reactable
22 23

  
23 24
  belongs_to :journalized, :polymorphic => true
24 25
  # added as a quick fix to allow eager loading of the polymorphic association
app/models/message.rb
19 19

  
20 20
class Message < ApplicationRecord
21 21
  include Redmine::SafeAttributes
22
  include Redmine::Reaction::Reactable
23

  
22 24
  belongs_to :board
23 25
  belongs_to :author, :class_name => 'User'
24 26
  acts_as_tree :counter_cache => :replies_count, :order => "#{Message.table_name}.created_on ASC"
app/models/news.rb
19 19

  
20 20
class News < ApplicationRecord
21 21
  include Redmine::SafeAttributes
22
  include Redmine::Reaction::Reactable
23

  
22 24
  belongs_to :project
23 25
  belongs_to :author, :class_name => 'User'
24 26
  has_many :comments, lambda {order("created_on")}, :as => :commented, :dependent => :delete_all
app/models/reaction.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
class Reaction < ApplicationRecord
21
  belongs_to :reactable, polymorphic: true
22
  belongs_to :user
23

  
24
  validates :reactable_type, inclusion: { in: Redmine::Reaction::REACTABLE_TYPES }
25

  
26
  scope :by, ->(user) { where(user: user) }
27

  
28
  # Returns a mapping of reactable IDs to an array of user names
29
  #
30
  # Returns:
31
  # {
32
  #   1 => ["Alice", "Bob"],
33
  #   2 => ["Charlie"],
34
  #   ...
35
  # }
36
  def self.users_map_for_reactables(reactable_type, reactable_ids)
37
    reactions = preload(:user)
38
                  .select(:reactable_id, :user_id)
39
                  .where(reactable_type: reactable_type, reactable_id: reactable_ids)
40
                  .order(id: :desc)
41

  
42
    reactable_user_pairs = reactions.map do |reaction|
43
      [reaction.reactable_id, reaction.user.name]
44
    end
45

  
46
    # Group by reactable_id and transform values to extract only user name
47
    # [[1, "Alice"], [1, "Bob"], [2, "Charlie"], ...]
48
    # =>
49
    # { 1 => ["Alice", "Bob"], 2 => ["Charlie"], ...}
50
    reactable_user_pairs
51
      .group_by(&:first)
52
      .transform_values { |pairs| pairs.map(&:last) }
53
  end
54
end
app/models/user.rb
93 93
  has_one :api_token, lambda {where "action='api'"}, :class_name => 'Token'
94 94
  has_one :email_address, lambda {where :is_default => true}, :autosave => true
95 95
  has_many :email_addresses, :dependent => :delete_all
96
  has_many :reactions, dependent: :delete_all
96 97
  belongs_to :auth_source
97 98

  
98 99
  scope :logged, lambda {where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}")}
app/views/issues/show.html.erb
39 39

  
40 40
<div class="subject">
41 41
<%= render_issue_subject_with_tree(@issue) %>
42
</div>
43

  
44
<div class="reaction">
45
  <%= reaction_button @issue %>
42 46
</div>
43 47
        <p class="author">
44 48
        <%= authoring @issue.created_on, @issue.author %>.
app/views/messages/show.html.erb
27 27
<h2><%= avatar(@topic.author) %><%= @topic.subject %></h2>
28 28

  
29 29
<div class="message">
30
<div class="reaction">
31
  <%= reaction_button @topic %>
32
</div>
30 33
<p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p>
31 34
<div id="message_topic_wiki" class="wiki">
32 35
<%= textilizable(@topic, :content) %>
......
44 47
<% @replies.each do |message| %>
45 48
  <div class="message reply" id="<%= "message-#{message.id}" %>">
46 49
    <div class="contextual">
50
      <%= reaction_button message %>
47 51
      <%= quote_reply(
48 52
            url_for(:action => 'quote', :id => message, :format => 'js'),
49 53
            "#message-#{message.id} .wiki",
app/views/news/show.html.erb
22 22
</div>
23 23
<% end %>
24 24

  
25
<p><% unless @news.summary.blank? %><em><%= @news.summary %></em><br /><% end %>
26
<span class="author"><%= authoring @news.created_on, @news.author %></span></p>
27
<div class="wiki">
28
<%= textilizable(@news, :description) %>
25
<div class="news">
26
  <div class="reaction">
27
    <%= reaction_button @news %>
28
  </div>
29
  <p><% unless @news.summary.blank? %><em><%= @news.summary %></em><br /><% end %>
30
  <span class="author"><%= authoring @news.created_on, @news.author %></span></p>
31
  <div class="wiki">
32
  <%= textilizable(@news, :description) %>
33
  </div>
34
  <%= link_to_attachments @news %>
29 35
</div>
30
<%= link_to_attachments @news %>
31 36
<br />
32 37

  
33 38
<div id="comments" style="margin-bottom:16px;">
......
38 43
<% @comments.each do |comment| %>
39 44
    <% next if comment.new_record? %>
40 45
    <div class="contextual">
46
    <%= reaction_button comment %>
41 47
    <%= link_to_if_authorized sprite_icon('del', l(:button_delete)), { :controller => 'comments', :action => 'destroy', :id => @news, :comment_id => comment},
42 48
                              :data => {:confirm => l(:text_are_you_sure)}, :method => :delete,
43 49
                              :title => l(:button_delete),
app/views/reactions/_replace_button.js.erb
1
(() => {
2
  const button = $('[data-reaction-button-id=<%= reaction_id_for @object %>]');
3

  
4
  removeHoverTooltips(button);
5
  button.html($('<%=j reaction_button @object, @reaction %>').children());
6
  setupHoverTooltips(button);
7
})();
app/views/reactions/create.js.erb
1
<%= render 'replace_button' %>
app/views/reactions/destroy.js.erb
1
<%= render 'replace_button' %>
app/views/settings/_general.html.erb
37 37

  
38 38
<p><%= setting_text_field :feeds_limit, :size => 6 %></p>
39 39

  
40
<p>
41
  <%= setting_check_box :reactions_enabled %>
42
  <em class="info">
43
    <%= l(:reaction_text_enabling_reactions) %>
44
  </em>
45
</p>
46

  
40 47
<%= call_hook(:view_settings_general_form) %>
41 48
</div>
42 49

  
config/icon_source.yml
221 221
- name: unwatch
222 222
  svg: eye-off
223 223
- name: copy-pre-content
224
  svg: clipboard
224
  svg: clipboard
225
- name: thumb-up
226
  svg: thumb-up
227
- name: thumb-up-filled
228
  svg: thumb-up
229
  style: filled
config/locales/en.yml
528 528
  setting_twofa: Two-factor authentication
529 529
  setting_related_issues_default_columns: Related and sub issues list defaults
530 530
  setting_display_related_issues_table_headers: Show table headers
531
  setting_reactions_enabled: Enable reactions
531 532

  
532 533
  permission_add_project: Create project
533 534
  permission_add_subprojects: Create subprojects
......
1432 1433
  text_project_destroy_enter_identifier: "To confirm, please enter the project's identifier (%{identifier}) below."
1433 1434
  field_name_or_email_or_login: Name, email or login
1434 1435
  setting_wiki_tablesort_enabled: Javascript based table sorting in wiki content
1436

  
1437
  reaction_text_enabling_reactions: "This enables reactions in issues, forums, and news."
1438
  reaction_text_x_other_users:
1439
    one: "1 other"
1440
    other: "%{count} others"
config/locales/ja.yml
1458 1458
  setting_display_related_issues_table_headers: Show table headers
1459 1459
  error_can_not_remove_role_reason_members_html: "<p>The following projects have members
1460 1460
    with this role:<br>%{projects}</p>"
1461

  
1462
  setting_reactions_enabled: リアクション機能を有効にする
1463
  reaction_text_enabling_reactions: "チケット、フォーラム、ニュースでリアクション機能を有効にします。"
1464
  reaction_text_x_other_users:
1465
    one: 他1人
1466
    other: "他%{count}人"
config/routes.rb
61 61
    end
62 62
  end
63 63

  
64
  resources :reactions, only: [:create, :destroy]
65

  
64 66
  get '/projects/:project_id/issues/gantt', :to => 'gantts#show', :as => 'project_gantt'
65 67
  get '/issues/gantt', :to => 'gantts#show'
66 68

  
config/settings.yml
363 363
  default: 1
364 364
wiki_tablesort_enabled:
365 365
  default: 1
366
reactions_enabled:
367
  default: 1
db/migrate/20250423065135_create_reactions.rb
1
class CreateReactions < ActiveRecord::Migration[7.2]
2
  def change
3
    create_table :reactions do |t|
4
      t.references :reactable, polymorphic: true, null: false
5
      t.references :user, null: false
6
      t.timestamps null: false
7
    end
8
    add_index :reactions, [:reactable_type, :reactable_id, :user_id], unique: true
9
    add_index :reactions, [:reactable_type, :reactable_id, :id]
10
  end
11
end
lib/redmine/reaction.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module Redmine
21
  module Reaction
22
    # Types of objects that can have reactions
23
    REACTABLE_TYPES = %w(Journal Issue Message News Comment)
24

  
25
    # Maximum number of users to display in the reaction button tooltip
26
    DISPLAY_REACTION_USERS_LIMIT = 10
27

  
28
    module Reactable
29
      extend ActiveSupport::Concern
30

  
31
      included do
32
        has_many :reactions, -> { order(id: :desc) }, as: :reactable, dependent: :delete_all
33
        has_many :reaction_users, through: :reactions, source: :user
34

  
35
        attr_writer :reaction_user_names, :reaction_count
36
      end
37

  
38
      class_methods do
39
        def load_with_reactions
40
          objects = all.to_a
41

  
42
          return objects unless Setting.reactions_enabled?
43

  
44
          object_users_map = ::Reaction.users_map_for_reactables(self.name, objects.map(&:id))
45

  
46
          objects.each do |object|
47
            all_user_names = object_users_map[object.id] || []
48

  
49
            object.reaction_count = all_user_names.size
50
            object.reaction_user_names = all_user_names.take(DISPLAY_REACTION_USERS_LIMIT)
51
          end
52
          objects
53
        end
54
      end
55

  
56
      def reaction_user_names
57
        @reaction_user_names || reaction_users.take(DISPLAY_REACTION_USERS_LIMIT).map(&:name)
58
      end
59

  
60
      def reaction_count
61
        @reaction_count || reaction_users.size
62
      end
63

  
64
      def reaction_by(user)
65
        if reactions.loaded?
66
          reactions.find { _1.user_id == user.id }
67
        else
68
          reactions.find_by(user: user)
69
        end
70
      end
71
    end
72
  end
73
end
(7-7/9)