+
+
+
+ +
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} + +