From d9af83fd7bcee5951c1aa36925bb454178b77730 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 10 Jun 2025 16:47:12 +0100 Subject: [PATCH] DEV: Reimplement widget UI as glimmer components (#243) This aims to be a 1:1 port of functionality and HTML structure. In future, this plugin may benefit from a refactor to use float-kit. Also unskips the system spec and makes improvements to prevent flakiness. --- .../discourse/components/vote-box.gjs | 125 ++++++++++++++++++ .../discourse/components/vote-button.gjs | 104 +++++++++++++++ .../discourse/components/vote-count.gjs | 95 +++++++++++++ .../discourse/components/vote-options.gjs | 29 ++++ .../topic-title-voting.gjs | 8 +- .../user-voted-topics.gjs | 19 +-- .../initializers/discourse-topic-voting.js | 7 +- .../extend-category-for-voting.js | 18 +++ .../discourse/widgets/remove-vote.js | 19 --- .../javascripts/discourse/widgets/vote-box.js | 104 --------------- .../discourse/widgets/vote-button.js | 101 -------------- .../discourse/widgets/vote-count.js | 90 ------------- .../discourse/widgets/vote-options.js | 36 ----- spec/system/voting_spec.rb | 28 ++-- 14 files changed, 395 insertions(+), 388 deletions(-) create mode 100644 assets/javascripts/discourse/components/vote-box.gjs create mode 100644 assets/javascripts/discourse/components/vote-button.gjs create mode 100644 assets/javascripts/discourse/components/vote-count.gjs create mode 100644 assets/javascripts/discourse/components/vote-options.gjs delete mode 100644 assets/javascripts/discourse/widgets/remove-vote.js delete mode 100644 assets/javascripts/discourse/widgets/vote-box.js delete mode 100644 assets/javascripts/discourse/widgets/vote-button.js delete mode 100644 assets/javascripts/discourse/widgets/vote-count.js delete mode 100644 assets/javascripts/discourse/widgets/vote-options.js diff --git a/assets/javascripts/discourse/components/vote-box.gjs b/assets/javascripts/discourse/components/vote-box.gjs new file mode 100644 index 0000000..0125846 --- /dev/null +++ b/assets/javascripts/discourse/components/vote-box.gjs @@ -0,0 +1,125 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { hash } from "@ember/helper"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import { htmlSafe } from "@ember/template"; +import concatClass from "discourse/helpers/concat-class"; +import routeAction from "discourse/helpers/route-action"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import closeOnClickOutside from "discourse/modifiers/close-on-click-outside"; +import { i18n } from "discourse-i18n"; +import VoteButton from "./vote-button"; +import VoteCount from "./vote-count"; +import VoteOptions from "./vote-options"; + +export default class VoteBox extends Component { + @service siteSettings; + @service currentUser; + + @tracked votesAlert; + @tracked allowClick = true; + @tracked initialVote = false; + @tracked showOptions = false; + + @action + addVote() { + let topic = this.args.topic; + return ajax("/voting/vote", { + type: "POST", + data: { + topic_id: topic.id, + }, + }) + .then((result) => { + topic.vote_count = result.vote_count; + topic.user_voted = true; + this.currentUser.votes_exceeded = !result.can_vote; + this.currentUser.votes_left = result.votes_left; + if (result.alert) { + this.votesAlert = result.votes_left; + } + this.allowClick = true; + this.showOptions = false; + }) + .catch(popupAjaxError); + } + + @action + removeVote() { + const topic = this.args.topic; + + return ajax("/voting/unvote", { + type: "POST", + data: { + topic_id: topic.id, + }, + }) + .then((result) => { + topic.vote_count = result.vote_count; + topic.user_voted = false; + this.currentUser.votes_exceeded = !result.can_vote; + this.currentUser.votes_left = result.votes_left; + this.allowClick = true; + this.showOptions = false; + }) + .catch(popupAjaxError); + } + + @action + showVoteOptions() { + this.showOptions = true; + } + + @action + closeVoteOptions() { + this.showOptions = false; + } + + @action + closeVotesAlert() { + this.votesAlert = null; + } + + +} diff --git a/assets/javascripts/discourse/components/vote-button.gjs b/assets/javascripts/discourse/components/vote-button.gjs new file mode 100644 index 0000000..ad60e56 --- /dev/null +++ b/assets/javascripts/discourse/components/vote-button.gjs @@ -0,0 +1,104 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import cookie from "discourse/lib/cookie"; +import { applyBehaviorTransformer } from "discourse/lib/transformer"; +import { i18n } from "discourse-i18n"; + +export default class VoteBox extends Component { + @service siteSettings; + @service currentUser; + + get wrapperClasses() { + const classes = []; + const { topic } = this.args; + if (topic.closed) { + classes.push("voting-closed"); + } else { + if (!topic.user_voted) { + classes.push("nonvote"); + } else { + if (this.currentUser && this.currentUser.votes_exceeded) { + classes.push("vote-limited nonvote"); + } else { + classes.push("vote"); + } + } + } + if (this.siteSettings.topic_voting_show_who_voted) { + classes.push("show-pointer"); + } + return classes.join(" "); + } + + get buttonContent() { + const { topic } = this.args; + if (this.currentUser) { + if (topic.closed) { + return i18n("topic_voting.voting_closed_title"); + } + + if (topic.user_voted) { + return i18n("topic_voting.voted_title"); + } + + if (this.currentUser.votes_exceeded) { + return i18n("topic_voting.voting_limit"); + } + + return i18n("topic_voting.vote_title"); + } + + if (topic.vote_count) { + return i18n("topic_voting.anonymous_button", { + count: topic.vote_count, + }); + } + + return i18n("topic_voting.anonymous_button", { count: 1 }); + } + + @action + click() { + applyBehaviorTransformer("topic-vote-button-click", () => { + if (!this.currentUser) { + cookie("destination_url", window.location.href, { path: "/" }); + this.args.showLogin(); + return; + } + + const { topic } = this.args; + + if ( + !topic.closed && + !topic.user_voted && + !this.currentUser.votes_exceeded + ) { + this.args.addVote(); + } + + if (topic.user_voted || this.currentUser.votes_exceeded) { + this.args.showVoteOptions(); + } + }); + } + + +} diff --git a/assets/javascripts/discourse/components/vote-count.gjs b/assets/javascripts/discourse/components/vote-count.gjs new file mode 100644 index 0000000..20d9a8a --- /dev/null +++ b/assets/javascripts/discourse/components/vote-count.gjs @@ -0,0 +1,95 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { hash } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import { eq } from "truth-helpers"; +import AsyncContent from "discourse/components/async-content"; +import SmallUserList from "discourse/components/small-user-list"; +import concatClass from "discourse/helpers/concat-class"; +import { ajax } from "discourse/lib/ajax"; +import cookie from "discourse/lib/cookie"; +import { bind } from "discourse/lib/decorators"; +import getURL from "discourse/lib/get-url"; +import closeOnClickOutside from "discourse/modifiers/close-on-click-outside"; + +export default class VoteBox extends Component { + @service siteSettings; + @service currentUser; + + @tracked showWhoVoted = false; + + @bind + async loadWhoVoted() { + return ajax("/voting/who", { + type: "GET", + data: { + topic_id: this.args.topic.id, + }, + }).then((users) => + users.map((user) => { + return { + template: user.avatar_template, + username: user.username, + post_url: user.post_url, + url: getURL("/u/") + user.username.toLowerCase(), + }; + }) + ); + } + + @action + click(event) { + event.preventDefault(); + event.stopPropagation(); + + if (!this.currentUser) { + cookie("destination_url", window.location.href, { path: "/" }); + this.args.showLogin(); + return; + } + + if (this.showWhoVoted) { + this.showWhoVoted = false; + } else if (this.siteSettings.topic_voting_show_who_voted) { + this.showWhoVoted = true; + } + } + + @action + clickOutside() { + this.showWhoVoted = false; + } + + +} diff --git a/assets/javascripts/discourse/components/vote-options.gjs b/assets/javascripts/discourse/components/vote-options.gjs new file mode 100644 index 0000000..d634c16 --- /dev/null +++ b/assets/javascripts/discourse/components/vote-options.gjs @@ -0,0 +1,29 @@ +import Component from "@glimmer/component"; +import { on } from "@ember/modifier"; +import { service } from "@ember/service"; +import icon from "discourse/helpers/d-icon"; +import { i18n } from "discourse-i18n"; + +export default class VoteBox extends Component { + @service currentUser; + + +} diff --git a/assets/javascripts/discourse/connectors/topic-above-post-stream/topic-title-voting.gjs b/assets/javascripts/discourse/connectors/topic-above-post-stream/topic-title-voting.gjs index 5e4088c..d5919e8 100644 --- a/assets/javascripts/discourse/connectors/topic-above-post-stream/topic-title-voting.gjs +++ b/assets/javascripts/discourse/connectors/topic-above-post-stream/topic-title-voting.gjs @@ -1,7 +1,7 @@ import Component from "@ember/component"; import { classNames, tagName } from "@ember-decorators/component"; -import MountWidget from "discourse/components/mount-widget"; import routeAction from "discourse/helpers/route-action"; +import VoteBox from "../../components/vote-box"; @tagName("div") @classNames("topic-above-post-stream-outlet", "topic-title-voting") @@ -11,10 +11,8 @@ export default class TopicTitleVoting extends Component { {{#if this.model.postStream.loaded}} {{#if this.model.postStream.firstPostPresent}}
- {{! template-lint-disable no-capital-arguments }} -
diff --git a/assets/javascripts/discourse/connectors/user-activity-bottom/user-voted-topics.gjs b/assets/javascripts/discourse/connectors/user-activity-bottom/user-voted-topics.gjs index f0413ef..48ec76d 100644 --- a/assets/javascripts/discourse/connectors/user-activity-bottom/user-voted-topics.gjs +++ b/assets/javascripts/discourse/connectors/user-activity-bottom/user-voted-topics.gjs @@ -1,18 +1,21 @@ -import Component from "@ember/component"; +import Component from "@glimmer/component"; import { LinkTo } from "@ember/routing"; -import { classNames, tagName } from "@ember-decorators/component"; +import { service } from "@ember/service"; import icon from "discourse/helpers/d-icon"; import { i18n } from "discourse-i18n"; -@tagName("") -@classNames("user-activity-bottom-outlet", "user-voted-topics") export default class UserVotedTopics extends Component { + @service siteSettings; + } diff --git a/assets/javascripts/discourse/initializers/discourse-topic-voting.js b/assets/javascripts/discourse/initializers/discourse-topic-voting.js index 62f2128..1a0a92a 100644 --- a/assets/javascripts/discourse/initializers/discourse-topic-voting.js +++ b/assets/javascripts/discourse/initializers/discourse-topic-voting.js @@ -6,7 +6,7 @@ export default { name: "discourse-topic-voting", initialize() { - withPluginApi("0.8.32", (api) => { + withPluginApi((api) => { const siteSettings = api.container.lookup("service:site-settings"); if (siteSettings.topic_voting_enabled) { const pageSearchController = api.container.lookup( @@ -57,16 +57,11 @@ export default { }, }); } - }); - withPluginApi("0.11.7", (api) => { - const siteSettings = api.container.lookup("service:site-settings"); if (siteSettings.topic_voting_enabled) { api.addSearchSuggestion("order:votes"); } - }); - withPluginApi("2.1.0", (api) => { api.registerValueTransformer( "category-available-views", ({ value, context }) => { diff --git a/assets/javascripts/discourse/pre-initializers/extend-category-for-voting.js b/assets/javascripts/discourse/pre-initializers/extend-category-for-voting.js index b773dfa..9476cac 100644 --- a/assets/javascripts/discourse/pre-initializers/extend-category-for-voting.js +++ b/assets/javascripts/discourse/pre-initializers/extend-category-for-voting.js @@ -1,3 +1,4 @@ +import { tracked } from "@glimmer/tracking"; import { withPluginApi } from "discourse/lib/plugin-api"; import { i18n } from "discourse-i18n"; @@ -35,6 +36,23 @@ function initialize(api) { }, { priority: -100 } ); + + api.modifyClass( + "model:topic", + (Superclass) => + class extends Superclass { + @tracked vote_count; + @tracked user_voted; + } + ); + api.modifyClass( + "model:user", + (Superclass) => + class extends Superclass { + @tracked votes_exceeded; + @tracked votes_left; + } + ); } export default { diff --git a/assets/javascripts/discourse/widgets/remove-vote.js b/assets/javascripts/discourse/widgets/remove-vote.js deleted file mode 100644 index e6a1b69..0000000 --- a/assets/javascripts/discourse/widgets/remove-vote.js +++ /dev/null @@ -1,19 +0,0 @@ -import { iconNode } from "discourse/lib/icon-library"; -import { createWidget } from "discourse/widgets/widget"; -import { i18n } from "discourse-i18n"; - -export default createWidget("remove-vote", { - tagName: "div.remove-vote", - - buildClasses() { - return "vote-option"; - }, - - html() { - return [iconNode("xmark"), i18n("topic_voting.remove_vote")]; - }, - - click() { - this.sendWidgetAction("removeVote"); - }, -}); diff --git a/assets/javascripts/discourse/widgets/vote-box.js b/assets/javascripts/discourse/widgets/vote-box.js deleted file mode 100644 index 829a199..0000000 --- a/assets/javascripts/discourse/widgets/vote-box.js +++ /dev/null @@ -1,104 +0,0 @@ -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import RawHtml from "discourse/widgets/raw-html"; -import { createWidget } from "discourse/widgets/widget"; -import { i18n } from "discourse-i18n"; - -export default createWidget("vote-box", { - tagName: "div.voting-wrapper", - buildKey: () => "vote-box", - - buildClasses() { - if (this.siteSettings.topic_voting_show_who_voted) { - return "show-pointer"; - } - }, - - defaultState() { - return { allowClick: true, initialVote: false }; - }, - - html(attrs, state) { - let voteCount = this.attach("vote-count", attrs); - let voteButton = this.attach("vote-button", attrs); - let voteOptions = this.attach("vote-options", attrs); - let contents = [voteCount, voteButton, voteOptions]; - - if (state.votesAlert > 0) { - const html = - ""; - contents.push(new RawHtml({ html })); - } - - return contents; - }, - - hideVotesAlert() { - if (this.state.votesAlert) { - this.state.votesAlert = null; - this.scheduleRerender(); - } - }, - - click() { - this.hideVotesAlert(); - }, - - clickOutside() { - this.hideVotesAlert(); - }, - - addVote() { - let topic = this.attrs; - let state = this.state; - return ajax("/voting/vote", { - type: "POST", - data: { - topic_id: topic.id, - }, - }) - .then((result) => { - topic.set("vote_count", result.vote_count); - topic.set("user_voted", true); - this.currentUser.setProperties({ - votes_exceeded: !result.can_vote, - votes_left: result.votes_left, - }); - if (result.alert) { - state.votesAlert = result.votes_left; - } - topic.set("who_voted", result.who_voted); - state.allowClick = true; - this.scheduleRerender(); - }) - .catch(popupAjaxError); - }, - - removeVote() { - let topic = this.attrs; - let state = this.state; - return ajax("/voting/unvote", { - type: "POST", - data: { - topic_id: topic.id, - }, - }) - .then((result) => { - topic.set("vote_count", result.vote_count); - topic.set("user_voted", false); - this.currentUser.setProperties({ - votes_exceeded: !result.can_vote, - votes_left: result.votes_left, - }); - topic.set("who_voted", result.who_voted); - state.allowClick = true; - this.scheduleRerender(); - }) - .catch(popupAjaxError); - }, -}); diff --git a/assets/javascripts/discourse/widgets/vote-button.js b/assets/javascripts/discourse/widgets/vote-button.js deleted file mode 100644 index ce108ae..0000000 --- a/assets/javascripts/discourse/widgets/vote-button.js +++ /dev/null @@ -1,101 +0,0 @@ -import { h } from "virtual-dom"; -import cookie from "discourse/lib/cookie"; -import { applyBehaviorTransformer } from "discourse/lib/transformer"; -import { createWidget } from "discourse/widgets/widget"; -import { i18n } from "discourse-i18n"; - -export default createWidget("vote-button", { - tagName: "div", - - buildClasses(attrs) { - let buttonClass = ""; - if (attrs.closed) { - buttonClass = "voting-closed"; - } else { - if (!attrs.user_voted) { - buttonClass = "nonvote"; - } else { - if (this.currentUser && this.currentUser.votes_exceeded) { - buttonClass = "vote-limited nonvote"; - } else { - buttonClass = "vote"; - } - } - } - if (this.siteSettings.topic_voting_show_who_voted) { - buttonClass += " show-pointer"; - } - return buttonClass; - }, - - buildButtonTitle(attrs) { - if (this.currentUser) { - if (attrs.closed) { - return i18n("topic_voting.voting_closed_title"); - } - - if (attrs.user_voted) { - return i18n("topic_voting.voted_title"); - } - - if (this.currentUser.votes_exceeded) { - return i18n("topic_voting.voting_limit"); - } - - return i18n("topic_voting.vote_title"); - } - - if (attrs.vote_count) { - return i18n("topic_voting.anonymous_button", { - count: attrs.vote_count, - }); - } - - return i18n("topic_voting.anonymous_button", { count: 1 }); - }, - - html(attrs) { - return h( - "button", - { - attributes: { - title: this.currentUser - ? i18n("topic_voting.votes_left_button_title", { - count: this.currentUser.votes_left, - }) - : "", - }, - className: "btn btn-primary vote-button", - }, - this.buildButtonTitle(attrs) - ); - }, - - click() { - applyBehaviorTransformer("topic-vote-button-click", () => { - if (!this.currentUser) { - this.sendWidgetAction("showLogin"); - cookie("destination_url", window.location.href, { path: "/" }); - return; - } - if ( - !this.attrs.closed && - this.parentWidget.state.allowClick && - !this.attrs.user_voted && - !this.currentUser.votes_exceeded - ) { - this.parentWidget.state.allowClick = false; - this.parentWidget.state.initialVote = true; - this.sendWidgetAction("addVote"); - } - if (this.attrs.user_voted || this.currentUser.votes_exceeded) { - document.querySelector(".vote-options").classList.toggle("hidden"); - } - }); - }, - - clickOutside() { - document.querySelector(".vote-options").classList.add("hidden"); - this.parentWidget.state.initialVote = false; - }, -}); diff --git a/assets/javascripts/discourse/widgets/vote-count.js b/assets/javascripts/discourse/widgets/vote-count.js deleted file mode 100644 index f09de06..0000000 --- a/assets/javascripts/discourse/widgets/vote-count.js +++ /dev/null @@ -1,90 +0,0 @@ -import { h } from "virtual-dom"; -import { ajax } from "discourse/lib/ajax"; -import cookie from "discourse/lib/cookie"; -import getURL from "discourse/lib/get-url"; -import { createWidget } from "discourse/widgets/widget"; - -export default createWidget("vote-count", { - tagName: "div.vote-count-wrapper", - buildKey: () => "vote-count", - - buildClasses() { - if (this.attrs.vote_count === 0) { - return "no-votes"; - } - }, - - defaultState() { - return { whoVotedUsers: null }; - }, - - html(attrs) { - let voteCount = h("div.vote-count", attrs.vote_count.toString()); - let whoVoted = null; - if ( - this.siteSettings.topic_voting_show_who_voted && - this.state.whoVotedUsers && - this.state.whoVotedUsers.length > 0 - ) { - whoVoted = this.attach("small-user-list", { - users: this.state.whoVotedUsers, - addSelf: attrs.liked, - listClassName: "regular-votes", - }); - } - - let buffer = [voteCount]; - if (whoVoted) { - buffer.push(h("div.who-voted.popup-menu.voting-popup-menu", [whoVoted])); - } - return buffer; - }, - - click() { - if (!this.currentUser) { - this.sendWidgetAction("showLogin"); - cookie("destination_url", window.location.href, { path: "/" }); - return; - } - - if ( - this.siteSettings.topic_voting_show_who_voted && - this.attrs.vote_count > 0 - ) { - if (this.state.whoVotedUsers === null) { - return this.getWhoVoted(); - } else { - const whoVotedElement = document.querySelector(".who-voted"); - whoVotedElement.style.display = - whoVotedElement.style.display === "none" ? "block" : "none"; - } - } - }, - - clickOutside() { - const whoVotedElement = document.querySelector(".who-voted"); - if (whoVotedElement) { - whoVotedElement.style.display = "none"; - } - }, - - getWhoVoted() { - return ajax("/voting/who", { - type: "GET", - data: { - topic_id: this.attrs.id, - }, - }).then((users) => { - this.state.whoVotedUsers = users.map(whoVotedAvatars); - }); - }, -}); - -function whoVotedAvatars(user) { - return { - template: user.avatar_template, - username: user.username, - post_url: user.post_url, - url: getURL("/u/") + user.username.toLowerCase(), - }; -} diff --git a/assets/javascripts/discourse/widgets/vote-options.js b/assets/javascripts/discourse/widgets/vote-options.js deleted file mode 100644 index da95ace..0000000 --- a/assets/javascripts/discourse/widgets/vote-options.js +++ /dev/null @@ -1,36 +0,0 @@ -import { h } from "virtual-dom"; -import { createWidget } from "discourse/widgets/widget"; -import { i18n } from "discourse-i18n"; - -export default createWidget("vote-options", { - tagName: "div.vote-options", - - buildClasses() { - return "voting-popup-menu popup-menu hidden"; - }, - - html(attrs) { - let contents = []; - - if (attrs.user_voted) { - contents.push(this.attach("remove-vote", attrs)); - } else if ( - this.currentUser && - this.currentUser.votes_exceeded && - !attrs.user_voted - ) { - contents.push([ - h("div", i18n("topic_voting.reached_limit")), - h( - "p", - h( - "a", - { href: this.currentUser.get("path") + "/activity/votes" }, - i18n("topic_voting.list_votes") - ) - ), - ]); - } - return contents; - }, -}); diff --git a/spec/system/voting_spec.rb b/spec/system/voting_spec.rb index 59ad246..4236511 100644 --- a/spec/system/voting_spec.rb +++ b/spec/system/voting_spec.rb @@ -16,31 +16,23 @@ RSpec.describe "Topic voting", type: :system, js: true do fab!(:admin_page) { PageObjects::Pages::AdminSiteSettings.new } before do - SiteSetting.topic_voting_enabled = false - - admin.activate - user.activate - + SiteSetting.topic_voting_enabled = true sign_in(admin) end - skip "enables voting in category topics and votes" do + it "enables voting in category topics and votes" do category_page.visit(category1) expect(category_page).to have_no_css(category_page.votes) - # enables voting - admin_page.visit_filtered_plugin_setting("topic%20voting%20enabled").toggle_setting( - "topic_voting_enabled", - "Allow users to vote on topics?", - ) - + # enable voting in category category_page .visit_settings(category1) .toggle_setting("enable-topic-voting", "Allow users to vote on topics in this category") .save_settings - .back_to_category - # voting + try_until_success { expect(Category.can_vote?(category1.id)).to eq(true) } + + # make a vote category_page.visit(category1) expect(category_page).to have_css(category_page.votes) expect(category_page).to have_css(category_page.topic_with_vote_count(0), count: 2) @@ -50,14 +42,12 @@ RSpec.describe "Topic voting", type: :system, js: true do topic_page.vote expect(topic_page.vote_popup).to have_text("You have 9 votes left, see your votes") expect(topic_page.vote_count).to have_text("1") - topic_page.click_vote_popup_activity + # visit user activity page + topic_page.click_vote_popup_activity expect(user_page.active_user_primary_navigation).to have_text("Activity") expect(user_page.active_user_secondary_navigation).to have_text("Votes") - expect(page).to have_css(".topic-list-body tr[data-topic-id=\"#{topic1.id}\"]") - expect(find(".topic-list-body tr[data-topic-id=\"#{topic1.id}\"] a.voted")).to have_text( - "1 vote", - ) + expect(page).to have_css(".topic-list-body tr[data-topic-id=\"#{topic1.id}\"]", text: "1 vote") find(".topic-list-body tr[data-topic-id=\"#{topic1.id}\"] a.raw-link").click # unvoting