From f2ca6be4d5d29cee8eedf24919086684ee8afa35 Mon Sep 17 00:00:00 2001 From: celestora Date: Tue, 11 Oct 2022 19:04:43 +0300 Subject: [PATCH] Add polls (#743) --- ServiceAPI/Polls.php | 70 +++++ Web/Models/Entities/Poll.php | 295 ++++++++++++++++++ .../Exceptions/AlreadyVotedException.php | 7 + .../Exceptions/InvalidOptionException.php | 7 + Web/Models/Exceptions/PollLockedException.php | 8 + .../Exceptions/TooMuchOptionsException.php | 7 + Web/Models/Repositories/Polls.php | 23 ++ Web/Presenters/PollPresenter.php | 70 +++++ Web/Presenters/WallPresenter.php | 22 +- Web/Presenters/templates/@layout.xml | 54 +--- Web/Presenters/templates/Poll/Poll.xml | 44 +++ Web/Presenters/templates/Poll/PollResults.xml | 44 +++ Web/Presenters/templates/Poll/Voters.xml | 40 +++ Web/Presenters/templates/_includeCSS.xml | 52 +++ .../templates/components/attachment.xml | 2 + .../templates/components/textArea.xml | 8 + Web/Presenters/templates/components/wall.xml | 2 +- Web/di.yml | 2 + Web/routes.yml | 4 + Web/static/css/style.css | 90 +++++- Web/static/js/al_polls.js | 151 +++++++++ install/sqls/00034-polls.sql | 32 ++ locales/en.strings | 32 ++ locales/ru.strings | 32 ++ quirks.yml | 2 + 25 files changed, 1041 insertions(+), 59 deletions(-) create mode 100644 ServiceAPI/Polls.php create mode 100644 Web/Models/Entities/Poll.php create mode 100644 Web/Models/Exceptions/AlreadyVotedException.php create mode 100644 Web/Models/Exceptions/InvalidOptionException.php create mode 100644 Web/Models/Exceptions/PollLockedException.php create mode 100644 Web/Models/Exceptions/TooMuchOptionsException.php create mode 100644 Web/Models/Repositories/Polls.php create mode 100644 Web/Presenters/PollPresenter.php create mode 100644 Web/Presenters/templates/Poll/Poll.xml create mode 100644 Web/Presenters/templates/Poll/PollResults.xml create mode 100644 Web/Presenters/templates/Poll/Voters.xml create mode 100644 Web/Presenters/templates/_includeCSS.xml create mode 100644 Web/static/js/al_polls.js create mode 100644 install/sqls/00034-polls.sql diff --git a/ServiceAPI/Polls.php b/ServiceAPI/Polls.php new file mode 100644 index 00000000..9d3e2e7f --- /dev/null +++ b/ServiceAPI/Polls.php @@ -0,0 +1,70 @@ +user = $user; + $this->polls = new PollRepo; + } + + private function getPollHtml(int $poll): string + { + return Router::i()->execute("/poll$poll", "SAPI"); + } + + function vote(int $pollId, string $options, callable $resolve, callable $reject): void + { + $poll = $this->polls->get($pollId); + if(!$poll) { + $reject("Poll not found"); + return; + } + + try { + $options = explode(",", $options); + $poll->vote($this->user, $options); + } catch(AlreadyVotedException $ex) { + $reject("Poll state changed: user has already voted."); + return; + } catch(PollLockedException $ex) { + $reject("Poll state changed: poll has ended."); + return; + } catch(InvalidOptionException $ex) { + $reject("Foreign options passed."); + return; + } catch(UnexpectedValueException $ex) { + $reject("Too much options passed."); + return; + } + + $resolve(["html" => $this->getPollHtml($pollId)]); + } + + function unvote(int $pollId, callable $resolve, callable $reject): void + { + $poll = $this->polls->get($pollId); + if(!$poll) { + $reject("Poll not found"); + return; + } + + try { + $poll->revokeVote($this->user); + } catch(PollLockedException $ex) { + $reject("Votes can't be revoked from this poll."); + return; + } + + $resolve(["html" => $this->getPollHtml($pollId)]); + } +} \ No newline at end of file diff --git a/Web/Models/Entities/Poll.php b/Web/Models/Entities/Poll.php new file mode 100644 index 00000000..43a97074 --- /dev/null +++ b/Web/Models/Entities/Poll.php @@ -0,0 +1,295 @@ +getRecord()->title; + } + + function getMetaDescription(): string + { + $props = []; + $props[] = tr($this->isAnonymous() ? "poll_anon" : "poll_public"); + if($this->isMultipleChoice()) $props[] = tr("poll_multi"); + if(!$this->isRevotable()) $props[] = tr("poll_lock"); + if(!is_null($this->endsAt())) $props[] = tr("poll_until", $this->endsAt()); + + return implode(" • ", $props); + } + + function getOwner(): User + { + return (new Users)->get($this->getRecord()->owner); + } + + function getOptions(): array + { + $options = $this->getRecord()->related("poll_options.poll"); + $res = []; + foreach($options as $opt) + $res[$opt->id] = $opt->name; + + return $res; + } + + function getUserVote(User $user): ?array + { + $ctx = DatabaseConnection::i()->getContext(); + $votedOpts = $ctx->table("poll_votes") + ->where(["user" => $user->getId(), "poll" => $this->getId()]); + + if($votedOpts->count() == 0) + return NULL; + + $res = []; + foreach($votedOpts as $votedOpt) { + $option = $ctx->table("poll_options")->get($votedOpt->option); + $res[] = [$option->id, $option->name]; + } + + return $res; + } + + function getVoters(int $optionId, int $page = 1, ?int $perPage = NULL): array + { + $res = []; + $ctx = DatabaseConnection::i()->getContext(); + $perPage = $perPage ?? OPENVK_DEFAULT_PER_PAGE; + $voters = $ctx->table("poll_votes")->where(["poll" => $this->getId(), "option" => $optionId]); + foreach($voters->page($page, $perPage) as $vote) + $res[] = (new Users)->get($vote->user); + + return $res; + } + + function getVoterCount(?int $optionId = NULL): int + { + $votes = DatabaseConnection::i()->getContext()->table("poll_votes"); + if(!$optionId) + return $votes->select("COUNT(DISTINCT user) AS c")->where("poll", $this->getId())->fetch()->c; + + return $votes->where(["poll" => $this->getId(), "option" => $optionId])->count(); + } + + function getResults(?User $user = NULL): object + { + $ctx = DatabaseConnection::i()->getContext(); + $voted = NULL; + if(!is_null($user)) + $voted = $this->getUserVote($user); + + $result = (object) []; + $result->totalVotes = $this->getVoterCount(); + + $unsOptions = []; + foreach($this->getOptions() as $id => $title) { + $option = (object) []; + $option->id = $id; + $option->name = $title; + + $option->votes = $this->getVoterCount($id); + $option->pct = $result->totalVotes == 0 ? 0 : (($option->votes / $result->totalVotes) * 100); + $option->voters = $this->getVoters($id, 1, 10); + if(!$user || !$voted) + $option->voted = NULL; + else + $option->voted = in_array([$id, $title], $voted); + + $unsOptions[$id] = $option; + } + + $optionsC = sizeof($unsOptions); + $sOptions = $unsOptions; + usort($sOptions, function($a, $b) { return $a->votes <=> $b->votes; }); + for($i = 0; $i < $optionsC; $i++) + $unsOptions[$id]->rate = $optionsC - $i - 1; + + $result->options = array_values($unsOptions); + + return $result; + } + + function isAnonymous(): bool + { + return (bool) $this->getRecord()->is_anonymous; + } + + function isMultipleChoice(): bool + { + return (bool) $this->getRecord()->allows_multiple; + } + + function isRevotable(): bool + { + return (bool) $this->getRecord()->can_revote; + } + + function endsAt(): ?DateTime + { + if(!$this->getRecord()->until) + return NULL; + + return new DateTime($this->getRecord()->until); + } + + function hasEnded(): bool + { + if($this->getRecord()->ended) + return true; + + if(!is_null($this->getRecord()->until)) + return time() >= $this->getRecord()->until; + + return false; + } + + function hasVoted(User $user): bool + { + return !is_null($this->getUserVote($user)); + } + + function canVote(User $user): bool + { + return !$this->hasEnded() && !$this->hasVoted($user); + } + + function vote(User $user, array $optionIds): void + { + if($this->hasEnded()) + throw new PollLockedException; + + if($this->hasVoted($user)) + throw new AlreadyVotedException; + + $optionIds = array_map(function($x) { return (int) $x; }, array_unique($optionIds)); + $validOpts = array_keys($this->getOptions()); + if(empty($optionIds) || (sizeof($optionIds) > 1 && !$this->isMultipleChoice())) + throw new UnexpectedValueException; + + if(sizeof(array_diff($optionIds, $validOpts)) > 0) + throw new InvalidOptionException; + + foreach($optionIds as $opt) { + DatabaseConnection::i()->getContext()->table("poll_votes")->insert([ + "user" => $user->getId(), + "poll" => $this->getId(), + "option" => $opt, + ]); + } + } + + function revokeVote(User $user): void + { + if(!$this->isRevotable()) + throw new PollLockedException; + + $this->getRecord()->related("poll_votes.poll") + ->where("user", $user->getId())->delete(); + } + + function setOwner(User $owner): void + { + $this->stateChanges("owner", $owner->getId()); + } + + function setEndDate(int $timestamp): void + { + if(!is_null($this->getRecord())) + throw new PollLockedException; + + $this->stateChanges("until", $timestamp); + } + + function setEnded(): void + { + $this->stateChanges("ended", 1); + } + + function setOptions(array $options): void + { + if(!is_null($this->getRecord())) + throw new PollLockedException; + + if(sizeof($options) > ovkGetQuirk("polls.max-opts")) + throw new TooMuchOptionsException; + + $this->choicesToPersist = $options; + } + + function setRevotability(bool $canReVote): void + { + if(!is_null($this->getRecord())) + throw new PollLockedException; + + $this->stateChanges("can_revote", true); + } + + function setAnonymity(bool $anonymous): void + { + $this->stateChanges("is_anonymous", $anonymous); + } + + function setMultipleChoice(bool $mc): void + { + $this->stateChanges("allows_multiple", $mc); + } + + function importXML(User $owner, string $xml): void + { + $xml = simplexml_load_string($xml); + $this->setOwner($owner); + $this->setTitle($xml["title"] ?? "Untitled"); + $this->setMultipleChoice(($xml["multiple"] ?? "no") == "yes"); + $this->setAnonymity(($xml["anonymous"] ?? "no") == "yes"); + $this->setRevotability(($xml["locked"] ?? "no") == "yes"); + if(ctype_digit($xml["duration"] ?? "")) + $this->setEndDate(time() + ((86400 * (int) $xml["duration"]))); + + $options = []; + foreach($xml->options->option as $opt) + $options[] = (string) $opt; + + if(empty($options)) + throw new UnexpectedValueException; + + $this->setOptions($options); + } + + static function import(User $owner, string $xml): Poll + { + $poll = new Poll; + $poll->importXML($owner, $xml); + $poll->save(); + + return $poll; + } + + function save(): void + { + if(empty($this->choicesToPersist)) + throw new InvalidStateException; + + parent::save(); + foreach($this->choicesToPersist as $option) { + DatabaseConnection::i()->getContext()->table("poll_options")->insert([ + "poll" => $this->getId(), + "name" => $option, + ]); + } + } +} \ No newline at end of file diff --git a/Web/Models/Exceptions/AlreadyVotedException.php b/Web/Models/Exceptions/AlreadyVotedException.php new file mode 100644 index 00000000..08363b9a --- /dev/null +++ b/Web/Models/Exceptions/AlreadyVotedException.php @@ -0,0 +1,7 @@ +polls = DatabaseConnection::i()->getContext()->table("polls"); + } + + function get(int $id): ?Poll + { + $poll = $this->polls->get($id); + if(!$poll) + return NULL; + + return new Poll($poll); + } +} \ No newline at end of file diff --git a/Web/Presenters/PollPresenter.php b/Web/Presenters/PollPresenter.php new file mode 100644 index 00000000..3f7084c5 --- /dev/null +++ b/Web/Presenters/PollPresenter.php @@ -0,0 +1,70 @@ +polls = $polls; + + parent::__construct(); + } + + function renderView(int $id): void + { + $poll = $this->polls->get($id); + if(!$poll) + $this->notFound(); + + $this->template->id = $poll->getId(); + $this->template->title = $poll->getTitle(); + $this->template->isAnon = $poll->isAnonymous(); + $this->template->multiple = $poll->isMultipleChoice(); + $this->template->unlocked = $poll->isRevotable(); + $this->template->until = $poll->endsAt(); + $this->template->votes = $poll->getVoterCount(); + $this->template->meta = $poll->getMetaDescription(); + if(is_null($this->user) || $poll->canVote($this->user->identity)) { + $this->template->options = $poll->getOptions(); + + $this->template->_template = "Poll/Poll.xml"; + return; + } + + $this->template->voted = $poll->hasVoted($this->user->identity); + $this->template->ended = $poll->hasEnded(); + $this->template->results = $poll->getResults($this->user->identity); + + $this->template->_template = "Poll/PollResults.xml"; + } + + function renderVoters(int $pollId): void + { + $poll = $this->polls->get($pollId); + if(!$poll) + $this->notFound(); + + if($poll->isAnonymous()) + $this->flashFail("err", tr("forbidden"), tr("poll_err_anonymous")); + + $options = $poll->getOptions(); + $option = (int) base_convert($this->queryParam("option"), 32, 10); + if(!in_array($option, array_keys($options))) + $this->notFound(); + + $page = (int) ($this->queryParam("p") ?? 1); + $voters = $poll->getVoters($option, $page); + + $this->template->pollId = $pollId; + $this->template->options = $options; + $this->template->option = [$option, $options[$option]]; + $this->template->tabs = $options; + $this->template->iterator = $voters; + $this->template->count = $poll->getVoterCount($option); + $this->template->page = $page; + } +} \ No newline at end of file diff --git a/Web/Presenters/WallPresenter.php b/Web/Presenters/WallPresenter.php index 55c90518..ef62d0ea 100644 --- a/Web/Presenters/WallPresenter.php +++ b/Web/Presenters/WallPresenter.php @@ -1,6 +1,7 @@ user->id, $this->postParam("text"), $_FILES["_pic_attachment"], $album, $anon); } - if($_FILES["_vid_attachment"]["error"] === UPLOAD_ERR_OK) { + if($_FILES["_vid_attachment"]["error"] === UPLOAD_ERR_OK) $video = Video::fastMake($this->user->id, $this->postParam("text"), $_FILES["_vid_attachment"], $anon); - } } catch(\DomainException $ex) { $this->flashFail("err", tr("failed_to_publish_post"), tr("media_file_corrupted")); } catch(ISE $ex) { $this->flashFail("err", tr("failed_to_publish_post"), tr("media_file_corrupted_or_too_large")); } - if(empty($this->postParam("text")) && !$photo && !$video) + try { + $poll = NULL; + $xml = $this->postParam("poll"); + if (!is_null($xml) && $xml != "none") + $poll = Poll::import($this->user->identity, $xml); + } catch(TooMuchOptionsException $e) { + $this->flashFail("err", tr("failed_to_publish_post"), tr("poll_err_to_much_options")); + } catch(\UnexpectedValueException $e) { + $this->flashFail("err", tr("failed_to_publish_post"), "Poll format invalid"); + } + + if(empty($this->postParam("text")) && !$photo && !$video && !$poll) $this->flashFail("err", tr("failed_to_publish_post"), tr("post_is_empty_or_too_big")); try { @@ -291,6 +302,9 @@ final class WallPresenter extends OpenVKPresenter if(!is_null($video)) $post->attach($video); + if(!is_null($poll)) + $post->attach($poll); + if($wall > 0 && $wall !== $this->user->identity->getId()) (new WallPostNotification($wallOwner, $post, $this->user->identity))->emit(); diff --git a/Web/Presenters/templates/@layout.xml b/Web/Presenters/templates/@layout.xml index 1be7ba55..8fe554c7 100644 --- a/Web/Presenters/templates/@layout.xml +++ b/Web/Presenters/templates/@layout.xml @@ -29,58 +29,7 @@ {script "js/timezone.js"} {/if} - {ifset $thisUser} - {if $thisUser->getNsfwTolerance() < 2} - {css "css/nsfw-posts.css"} - {/if} - - {if $theme !== NULL} - {if $theme->inheritDefault()} - {css "css/style.css"} - {css "css/dialog.css"} - {css "css/notifications.css"} - - {if $isXmas} - {css "css/xmas.css"} - {/if} - {/if} - - - - {if $isXmas} - - {/if} - {else} - {css "css/style.css"} - {css "css/dialog.css"} - {css "css/notifications.css"} - - {if $isXmas} - {css "css/xmas.css"} - {/if} - {/if} - - {if $thisUser->getStyleAvatar() == 1} - {css "css/avatar.1.css"} - {/if} - - {if $thisUser->getStyleAvatar() == 2} - {css "css/avatar.2.css"} - {/if} - - {if $thisUser->hasMicroblogEnabled() == 1} - {css "css/microblog.css"} - {/if} - {else} - {css "css/style.css"} - {css "css/dialog.css"} - {css "css/nsfw-posts.css"} - {css "css/notifications.css"} - - {if $isXmas} - {css "css/xmas.css"} - {/if} - {/ifset} + {include "_includeCSS.xml"} {ifset headIncludes} {include headIncludes} @@ -334,6 +283,7 @@ {script "js/al_wall.js"} {script "js/al_api.js"} {script "js/al_mentions.js"} + {script "js/al_polls.js"} {ifset $thisUser} {script "js/al_notifs.js"} diff --git a/Web/Presenters/templates/Poll/Poll.xml b/Web/Presenters/templates/Poll/Poll.xml new file mode 100644 index 00000000..21378ca1 --- /dev/null +++ b/Web/Presenters/templates/Poll/Poll.xml @@ -0,0 +1,44 @@ +{if !isset($parentModule) || substr($parentModule, 0, 21) === 'libchandler:absolute.'} + + + + {script "js/node_modules/jquery/dist/jquery.min.js"} + {script "js/node_modules/umbrellajs/umbrella.min.js"} + {script "js/node_modules/msgpack-lite/dist/msgpack.min.js"} + {script "js/messagebox.js"} + {script "js/l10n.js"} + {script "js/al_api.js"} + {script "js/al_polls.js"} + {include "../_includeCSS.xml"} + + +{/if} + +
+

{$title}

+
+
+
+ +
+
+ + {if $multiple} +
+ + {/if} +
+ +
+ {tr("poll_voter_count", $votes)|noescape}
+ {$meta} +
+
diff --git a/Web/Presenters/templates/Poll/PollResults.xml b/Web/Presenters/templates/Poll/PollResults.xml new file mode 100644 index 00000000..9b61c48a --- /dev/null +++ b/Web/Presenters/templates/Poll/PollResults.xml @@ -0,0 +1,44 @@ +{if !isset($parentModule) || substr($parentModule, 0, 21) === 'libchandler:absolute.'} + + + + {script "js/node_modules/jquery/dist/jquery.min.js"} + {script "js/node_modules/umbrellajs/umbrella.min.js"} + {script "js/node_modules/msgpack-lite/dist/msgpack.min.js"} + {script "js/messagebox.js"} + {script "js/l10n.js"} + {script "js/al_api.js"} + {script "js/al_polls.js"} + {include "../_includeCSS.xml"} + + +{/if} + +
+ {_retract_vote} +

{$title}

+
+
+ {if $isAnon} + {$option->name} + {else} + {$option->name} + {/if} + +
+
+ {$option->votes} +
 
+
+
+ {$option->pct}% +
+
+
+
+ +
+ {tr("poll_voter_count", $votes)|noescape}
+ {$meta} +
+
diff --git a/Web/Presenters/templates/Poll/Voters.xml b/Web/Presenters/templates/Poll/Voters.xml new file mode 100644 index 00000000..f6348df9 --- /dev/null +++ b/Web/Presenters/templates/Poll/Voters.xml @@ -0,0 +1,40 @@ +{extends "../@listView.xml"} + +{block title} + {_poll_voters_list} +{/block} + +{block header} + {_poll_voters_list} » + {$option[1]} +{/block} + +{block tabs} +
+ {$optionName} +
+{/block} + +{* BEGIN ELEMENTS DESCRIPTION *} + +{block link|strip|stripHtml} + {$x->getURL()} +{/block} + +{block preview} + Фотография пользователя +{/block} + +{block name} + {$x->getCanonicalName()} + +{/block} + +{block description} +{/block} + +{block actions} +{/block} diff --git a/Web/Presenters/templates/_includeCSS.xml b/Web/Presenters/templates/_includeCSS.xml new file mode 100644 index 00000000..7f83c01a --- /dev/null +++ b/Web/Presenters/templates/_includeCSS.xml @@ -0,0 +1,52 @@ +{ifset $thisUser} + {if $thisUser->getNsfwTolerance() < 2} + {css "css/nsfw-posts.css"} + {/if} + + {if $theme !== NULL} + {if $theme->inheritDefault()} + {css "css/style.css"} + {css "css/dialog.css"} + {css "css/notifications.css"} + + {if $isXmas} + {css "css/xmas.css"} + {/if} + {/if} + + + + {if $isXmas} + + {/if} + {else} + {css "css/style.css"} + {css "css/dialog.css"} + {css "css/notifications.css"} + + {if $isXmas} + {css "css/xmas.css"} + {/if} + {/if} + + {if $thisUser->getStyleAvatar() == 1} + {css "css/avatar.1.css"} + {/if} + + {if $thisUser->getStyleAvatar() == 2} + {css "css/avatar.2.css"} + {/if} + + {if $thisUser->hasMicroblogEnabled() == 1} + {css "css/microblog.css"} + {/if} +{else} + {css "css/style.css"} + {css "css/dialog.css"} + {css "css/nsfw-posts.css"} + {css "css/notifications.css"} + + {if $isXmas} + {css "css/xmas.css"} + {/if} +{/ifset} \ No newline at end of file diff --git a/Web/Presenters/templates/components/attachment.xml b/Web/Presenters/templates/components/attachment.xml index 6366a603..c54a1c03 100644 --- a/Web/Presenters/templates/components/attachment.xml +++ b/Web/Presenters/templates/components/attachment.xml @@ -11,6 +11,8 @@ {/if} {elseif $attachment instanceof \openvk\Web\Models\Entities\Video} +{elseif $attachment instanceof \openvk\Web\Models\Entities\Poll} + {presenter "openvk!Poll->view", $attachment->getId()} {elseif $attachment instanceof \openvk\Web\Models\Entities\Post} {php $GLOBALS["_nesAttGloCou"] = (isset($GLOBALS["_nesAttGloCou"]) ? $GLOBALS["_nesAttGloCou"] : 0) + 1} {if $GLOBALS["_nesAttGloCou"] > 2} diff --git a/Web/Presenters/templates/components/textArea.xml b/Web/Presenters/templates/components/textArea.xml index 0b733ec7..0d28e06e 100644 --- a/Web/Presenters/templates/components/textArea.xml +++ b/Web/Presenters/templates/components/textArea.xml @@ -11,6 +11,9 @@
{_attachment}: (unknown)
+
+ {_poll} +
{var $anonEnabled = OPENVK_ROOT_CONF['openvk']['preferences']['wall']['anonymousPosting']['enable']} @@ -50,6 +53,7 @@
+
@@ -75,6 +79,10 @@ {_graffiti} + + + {_poll} + diff --git a/Web/Presenters/templates/components/wall.xml b/Web/Presenters/templates/components/wall.xml index feb688bc..c2f5089a 100644 --- a/Web/Presenters/templates/components/wall.xml +++ b/Web/Presenters/templates/components/wall.xml @@ -8,7 +8,7 @@
- {include "../components/textArea.xml", route => "/wall$owner/makePost", graffiti => true} + {include "../components/textArea.xml", route => "/wall$owner/makePost", graffiti => true, polls => true}
diff --git a/Web/di.yml b/Web/di.yml index ec867809..152e5db1 100644 --- a/Web/di.yml +++ b/Web/di.yml @@ -23,9 +23,11 @@ services: - openvk\Web\Presenters\AppsPresenter - openvk\Web\Presenters\ThemepacksPresenter - openvk\Web\Presenters\VKAPIPresenter + - openvk\Web\Presenters\PollPresenter - openvk\Web\Presenters\BannedLinkPresenter - openvk\Web\Models\Repositories\Users - openvk\Web\Models\Repositories\Posts + - openvk\Web\Models\Repositories\Polls - openvk\Web\Models\Repositories\Photos - openvk\Web\Models\Repositories\Albums - openvk\Web\Models\Repositories\Clubs diff --git a/Web/routes.yml b/Web/routes.yml index d6f63a11..ec7fe02d 100644 --- a/Web/routes.yml +++ b/Web/routes.yml @@ -273,6 +273,10 @@ routes: handler: "Apps->edit" - url: "/apps/uninstall" handler: "Apps->unInstall" + - url: "/poll{num}" + handler: "Poll->view" + - url: "/poll{num}/voters" + handler: "Poll->voters" - url: "/admin" handler: "Admin->index" - url: "/admin/users" diff --git a/Web/static/css/style.css b/Web/static/css/style.css index 25516af7..b935c7e0 100644 --- a/Web/static/css/style.css +++ b/Web/static/css/style.css @@ -448,6 +448,15 @@ table { color: #e8e8e8; } +.button[disabled] { + filter: opacity(0.5); + cursor: not-allowed; +} + +.button[disabled]:hover { + color: #fff; +} + .button-loading { display: inline-block; background-image: url('/assets/packages/static/openvk/img/loading_mini.gif'); @@ -801,6 +810,8 @@ table.User { padding: 0 10px; margin-left: -10px; width: 607px; + overflow-x: auto; + white-space: nowrap; } .tabs.stupid-fix { @@ -1338,14 +1349,14 @@ body.scrolled .toTop:hover { font-weight: bold; } -.post-upload { +.post-upload, .post-has-poll { margin-top: 11px; margin-left: 3px; color: #3c3c3c; display: none; } -.post-upload::before { +.post-upload::before, .post-has-poll::before { content: " "; width: 8px; height: 8px; @@ -2051,6 +2062,81 @@ table td[width="120"] { max-height: 250px; } +.poll { + padding: 8px; + transition: .1s filter ease-in; + border: 1px solid #e3e3e3; +} + +.poll.loading { + filter: opacity(0.5); + cursor: progress; + user-select: none; +} + +.poll.loading * { + pointer-events: none; +} + +.poll-embed { + float: right; +} + +.poll h4 { + padding-bottom: 4px; + margin-bottom: 8px; +} + +.poll-meta .nobold { + font-style: oblique; +} + +.poll-result-barspace { + display: flex; +} + +.poll-result { + margin-bottom: 5px; +} + +.poll-result a { + color: unset; +} + +.poll-result-bar { + position: relative; + margin: 5px 0; + background-color: #f7f7f7; + height: 13pt; + flex: 14; +} + +span.poll-result-count { + position: absolute; + top: 50%; + left: 50%; + margin-right: -50%; + transform: translate(-50%, -50%); + color: #7d96af; +} + +.poll-result-bar-sub { + height: 100%; + background-color: #d9e1ea; +} + +.poll-result-pct { + flex: 2; + display: flex; + justify-content: flex-end; + align-items: center; + padding-left: 5px; +} + +a.poll-retract-vote { + float: right; +} + .tippy-box[data-theme~="vk"] { user-select: none; background-color: #fff; diff --git a/Web/static/js/al_polls.js b/Web/static/js/al_polls.js new file mode 100644 index 00000000..14588e04 --- /dev/null +++ b/Web/static/js/al_polls.js @@ -0,0 +1,151 @@ +async function pollRetractVote(id) { + let poll = $(`.poll[data-id=${id}]`); + + poll.addClass("loading"); + try { + let html = (await API.Polls.unvote(poll.data("id"))).html; + poll.prop("outerHTML", html); + } catch(e) { + MessageBox(tr("error"), "Sorry: " + e.message, ["OK"], [Function.noop]); + } finally { + poll.removeClass("loading"); + } +} + +async function pollFormSubmit(e, form) { + e.preventDefault(); + form = $(form); + + let options; + let isMultiple = form.data("multi"); + let pollId = form.data("pid"); + + let formData = form.serializeArray(); + if(!isMultiple) { + options = [Number(formData[0].value)]; + } else { + options = []; + formData.forEach(function(record) { + if(!record.name.startsWith("option") || record.value !== "on") + return; + + options.push(Number(record.name.substr(6))); + }); + } + + let poll = form.parent(); + poll.addClass("loading"); + try { + let html = (await API.Polls.vote(pollId, options.join(","))).html; + poll.prop("outerHTML", html); + } catch(e) { + MessageBox(tr("error"), "Sorry: " + e.message, ["OK"], [Function.noop]); + } finally { + poll.removeClass("loading"); + } +} + +function pollCheckBoxPressed(cb) { + cb = $(cb); + let form = cb.parent().parent().parent().parent(); + let checked = $("input:checked", form); + if(checked.length >= 1) + $("input[type=submit]", form).removeAttr("disabled"); + else + $("input[type=submit]", form).attr("disabled", "disabled"); +} + +function pollRadioPressed(radio) { + let form = $(radio).parent().parent().parent().parent(); + form.submit(); +} + +function initPoll(id) { + let form = $(`#wall-post-input${id}`).parent(); + + let mBody = ` +
+ +
+ +
+
+
+
+ +
${tr("poll_editor_tips")}
+
+ `; + + MessageBox(tr("create_poll"), mBody, [tr("attach"), tr("cancel")], [ + function() { + let dialog = $(this.$dialog().nodes[0]); + $("input", dialog).unbind(); + + let title = $("input[name=title]", dialog).val(); + let anon = $("input[name=anon]", dialog).prop("checked") ? "yes" : "no"; + let multi = $("input[name=multi]", dialog).prop("checked") ? "yes" : "no"; + let lock = $("input[name=locked]", dialog).prop("checked") ? "yes" : "no"; + let expires = "infinite"; + if($("input[name=expires]", dialog).prop("checked")) + expires = $("select[name=expires_in]", dialog).val(); + + let options = ""; + $(".poll-option", dialog).each(function() { + if($(this).val().length === 0) + return; + + options += ``; + }); + + let xml = ` + + ${options} + + `; + $("input[name=poll]", form).val(xml); + $(".post-has-poll", form).show(); + console.log(xml); + }, + function() { + $("input", $(this.$dialog().nodes[0])).unbind(); + } + ]); + + let editor = $(`#poll_editor${id}`); + $("input[name=newOption]", editor).bind("focus", function() { + let newOption = $(''); + newOption.appendTo($(".poll-options", editor)); + newOption.focus(); + newOption.bind("keydown", function(e) { + if(e.key === "Enter" && $(this).next().length === 0) { + $("input[name=newOption]", editor).focus(); + return; + } + + if($(this).val().length > 0) + return; + + if(e.key !== "Backspace") + return; + + if($(this).siblings().length === 0) + return; + + if($(this).prev().length === 0) + $(this).next().focus(); + else + $(this).prev().focus(); + + e.preventDefault(); + $(this).unbind("keydown"); + $(this).remove(); + }); + }); +} \ No newline at end of file diff --git a/install/sqls/00034-polls.sql b/install/sqls/00034-polls.sql new file mode 100644 index 00000000..8122549b --- /dev/null +++ b/install/sqls/00034-polls.sql @@ -0,0 +1,32 @@ +CREATE TABLE IF NOT EXISTS `polls` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `owner` bigint unsigned NOT NULL, + `title` text NOT NULL, + `allows_multiple` bit(1) NOT NULL DEFAULT b'0', + `is_anonymous` bit(1) NOT NULL DEFAULT b'0', + `can_revote` bit(1) NOT NULL DEFAULT b'0', + `until` bigint unsigned DEFAULT NULL, + `ended` bit(1) NOT NULL DEFAULT b'0', + `deleted` bit(1) NOT NULL DEFAULT b'0', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + +CREATE TABLE IF NOT EXISTS `poll_options` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `poll` bigint unsigned NOT NULL, + `name` varchar(512) NOT NULL, + PRIMARY KEY (`id`), + KEY `poll` (`poll`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + +CREATE TABLE IF NOT EXISTS `poll_votes` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `user` bigint unsigned NOT NULL, + `poll` bigint unsigned NOT NULL, + `option` bigint unsigned NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `user_option` (`user`,`option`), + KEY `option` (`option`), + KEY `poll` (`poll`), + KEY `user_poll` (`user`,`poll`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; diff --git a/locales/en.strings b/locales/en.strings index f49cc3b2..7ccedb95 100644 --- a/locales/en.strings +++ b/locales/en.strings @@ -876,6 +876,38 @@ "messages_error_1" = "Message not delivered"; "messages_error_1_description" = "There was a general error in sending this message..."; +/* Polls */ +"poll" = "Poll"; +"create_poll" = "Create poll"; +"poll_title" = "Ask a question"; +"poll_add_option" = "Add an option..."; +"poll_anonymous" = "Anonymous votes"; +"poll_multiple" = "Multiple answers"; +"poll_locked" = "Quiz mode (no retraction)"; +"poll_edit_expires" = "Expires in: "; +"poll_edit_expires_days" = "days"; +"poll_editor_tips" = "Pressing backspace in empty option will remove it. Use Tab/Enter (in last option) to create new options faster."; +"poll_embed" = "Embed code"; + +"poll_voter_count_zero" = "Be the first one to vote!"; +"poll_voter_count_one" = "Only one user voted."; +"poll_voter_count_few" = "$1 users voted."; +"poll_voter_count_many" = "$1 users voted."; +"poll_voter_count_other" = "$1 users voted."; + +"poll_voters_list" = "Voters"; + +"poll_anon" = "Anonymous"; +"poll_public" = "Public"; +"poll_multi" = "multiple choice"; +"poll_lock" = "can't revoke"; +"poll_until" = "until $1"; + +"poll_err_to_much_options" = "Too much options supplied."; +"poll_err_anonymous" = "Can't access voters list: poll is anonymous."; +"cast_vote" = "Vote!"; +"retract_vote" = "Cancel my vote"; + /* Discussions */ "discussions" = "Discussions"; diff --git a/locales/ru.strings b/locales/ru.strings index af599abb..eceadc43 100644 --- a/locales/ru.strings +++ b/locales/ru.strings @@ -920,6 +920,38 @@ "messages_error_1" = "Сообщение не доставлено"; "messages_error_1_description" = "При отправке этого сообщения произошла ошибка общего характера..."; +/* Polls */ +"poll" = "Опрос"; +"create_poll" = "Новый опрос"; +"poll_title" = "Тема опроса"; +"poll_add_option" = "Добавить вариант ответа"; +"poll_anonymous" = "Анонимный опрос"; +"poll_multiple" = "Множественный выбор"; +"poll_locked" = "Запретить отменять свой голос"; +"poll_edit_expires" = "Голосование истекает через: "; +"poll_edit_expires_days" = "дней"; +"poll_editor_tips" = "Нажатие Backspace в пустом варианте приводит к его удалению. Tab/Enter в последнем добавляет новый."; +"poll_embed" = "Получить код"; + +"poll_voter_count_zero" = "Будьте первым, кто проголосует!"; +"poll_voter_count_one" = "В опросе проголосовал один человек."; +"poll_voter_count_few" = "В опросе проголосовало $1 человека."; +"poll_voter_count_many" = "В опросе проголосовало $1 человек."; +"poll_voter_count_other" = "В опросе проголосовало $1 человек."; + +"poll_voters_list" = "Список проголосовавших"; + +"poll_anon" = "Анонимное голосование"; +"poll_public" = "Публичное голосование"; +"poll_multi" = "много вариантов"; +"poll_lock" = "нельзя переголосовать"; +"poll_until" = "до $1"; + +"poll_err_to_much_options" = "Слишком много вариантов в опросе."; +"poll_err_anonymous" = "Невозможно просмотреть список проголосовавших в анонимном голосовании."; +"cast_vote" = "Проголосовать"; +"retract_vote" = "Отменить голос"; + /* Discussions */ "discussions" = "Обсуждения"; diff --git a/quirks.yml b/quirks.yml index c7967fc8..8a1b7878 100644 --- a/quirks.yml +++ b/quirks.yml @@ -44,3 +44,5 @@ comments.allow-graffiti: 0 # + Set this option to -1 if you want to disable the limit # + Set this option to any non-negative number to be this limit wall.repost-liking-recursion-limit: 10 + +polls.max-opts: 10 \ No newline at end of file