Add polls (#743)

This commit is contained in:
celestora 2022-10-11 19:04:43 +03:00 committed by GitHub
parent d8a8dd920a
commit f2ca6be4d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1041 additions and 59 deletions

70
ServiceAPI/Polls.php Normal file
View file

@ -0,0 +1,70 @@
<?php declare(strict_types=1);
namespace openvk\ServiceAPI;
use Chandler\MVC\Routing\Router;
use openvk\Web\Models\Entities\User;
use openvk\Web\Models\Exceptions\{AlreadyVotedException, InvalidOptionException, PollLockedException};
use openvk\Web\Models\Repositories\Polls as PollRepo;
use UnexpectedValueException;
class Polls implements Handler
{
protected $user;
protected $polls;
function __construct(?User $user)
{
$this->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)]);
}
}

View file

@ -0,0 +1,295 @@
<?php declare(strict_types=1);
namespace openvk\Web\Models\Entities;
use openvk\Web\Models\Exceptions\TooMuchOptionsException;
use openvk\Web\Util\DateTime;
use \UnexpectedValueException;
use Nette\InvalidStateException;
use openvk\Web\Models\Repositories\Users;
use Chandler\Database\DatabaseConnection;
use openvk\Web\Models\Exceptions\PollLockedException;
use openvk\Web\Models\Exceptions\AlreadyVotedException;
use openvk\Web\Models\Exceptions\InvalidOptionException;
class Poll extends Attachable
{
protected $tableName = "polls";
private $choicesToPersist = [];
function getTitle(): string
{
return $this->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,
]);
}
}
}

View file

@ -0,0 +1,7 @@
<?php declare(strict_types=1);
namespace openvk\Web\Models\Exceptions;
final class AlreadyVotedException extends \RuntimeException
{
}

View file

@ -0,0 +1,7 @@
<?php declare(strict_types=1);
namespace openvk\Web\Models\Exceptions;
final class InvalidOptionException extends \UnexpectedValueException
{
}

View file

@ -0,0 +1,8 @@
<?php declare(strict_types=1);
namespace openvk\Web\Models\Exceptions;
use Nette\InvalidStateException;
final class PollLockedException extends InvalidStateException
{
}

View file

@ -0,0 +1,7 @@
<?php declare(strict_types=1);
namespace openvk\Web\Models\Exceptions;
final class TooMuchOptionsException extends \UnexpectedValueException
{
}

View file

@ -0,0 +1,23 @@
<?php declare(strict_types=1);
namespace openvk\Web\Models\Repositories;
use Chandler\Database\DatabaseConnection;
use openvk\Web\Models\Entities\Poll;
class Polls
{
private $polls;
function __construct()
{
$this->polls = DatabaseConnection::i()->getContext()->table("polls");
}
function get(int $id): ?Poll
{
$poll = $this->polls->get($id);
if(!$poll)
return NULL;
return new Poll($poll);
}
}

View file

@ -0,0 +1,70 @@
<?php declare(strict_types=1);
namespace openvk\Web\Presenters;
use openvk\Web\Models\Entities\Poll;
use openvk\Web\Models\Repositories\Polls;
final class PollPresenter extends OpenVKPresenter
{
private $polls;
function __construct(Polls $polls)
{
$this->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;
}
}

View file

@ -1,6 +1,7 @@
<?php declare(strict_types=1); <?php declare(strict_types=1);
namespace openvk\Web\Presenters; namespace openvk\Web\Presenters;
use openvk\Web\Models\Entities\{Post, Photo, Video, Club, User}; use openvk\Web\Models\Exceptions\TooMuchOptionsException;
use openvk\Web\Models\Entities\{Poll, Post, Photo, Video, Club, User};
use openvk\Web\Models\Entities\Notifications\{RepostNotification, WallPostNotification}; use openvk\Web\Models\Entities\Notifications\{RepostNotification, WallPostNotification};
use openvk\Web\Models\Repositories\{Posts, Users, Clubs, Albums}; use openvk\Web\Models\Repositories\{Posts, Users, Clubs, Albums};
use Chandler\Database\DatabaseConnection; use Chandler\Database\DatabaseConnection;
@ -259,16 +260,26 @@ final class WallPresenter extends OpenVKPresenter
$photo = Photo::fastMake($this->user->id, $this->postParam("text"), $_FILES["_pic_attachment"], $album, $anon); $photo = Photo::fastMake($this->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); $video = Video::fastMake($this->user->id, $this->postParam("text"), $_FILES["_vid_attachment"], $anon);
}
} catch(\DomainException $ex) { } catch(\DomainException $ex) {
$this->flashFail("err", tr("failed_to_publish_post"), tr("media_file_corrupted")); $this->flashFail("err", tr("failed_to_publish_post"), tr("media_file_corrupted"));
} catch(ISE $ex) { } catch(ISE $ex) {
$this->flashFail("err", tr("failed_to_publish_post"), tr("media_file_corrupted_or_too_large")); $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")); $this->flashFail("err", tr("failed_to_publish_post"), tr("post_is_empty_or_too_big"));
try { try {
@ -291,6 +302,9 @@ final class WallPresenter extends OpenVKPresenter
if(!is_null($video)) if(!is_null($video))
$post->attach($video); $post->attach($video);
if(!is_null($poll))
$post->attach($poll);
if($wall > 0 && $wall !== $this->user->identity->getId()) if($wall > 0 && $wall !== $this->user->identity->getId())
(new WallPostNotification($wallOwner, $post, $this->user->identity))->emit(); (new WallPostNotification($wallOwner, $post, $this->user->identity))->emit();

View file

@ -29,58 +29,7 @@
{script "js/timezone.js"} {script "js/timezone.js"}
{/if} {/if}
{ifset $thisUser} {include "_includeCSS.xml"}
{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}
<link rel="stylesheet" href="/themepack/{$theme->getId()}/{$theme->getVersion()}/stylesheet/styles.css" />
{if $isXmas}
<link rel="stylesheet" href="/themepack/{$theme->getId()}/{$theme->getVersion()}/resource/xmas.css" />
{/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}
{ifset headIncludes} {ifset headIncludes}
{include headIncludes} {include headIncludes}
@ -334,6 +283,7 @@
{script "js/al_wall.js"} {script "js/al_wall.js"}
{script "js/al_api.js"} {script "js/al_api.js"}
{script "js/al_mentions.js"} {script "js/al_mentions.js"}
{script "js/al_polls.js"}
{ifset $thisUser} {ifset $thisUser}
{script "js/al_notifs.js"} {script "js/al_notifs.js"}

View file

@ -0,0 +1,44 @@
{if !isset($parentModule) || substr($parentModule, 0, 21) === 'libchandler:absolute.'}
<link rel="shortcut icon" href="/assets/packages/static/openvk/img/icon.ico" />
<meta n:ifset="$csrfToken" name="csrf" value="{$csrfToken}" />
<script src="/language/{getLanguage()}.js" crossorigin="anonymous"></script>
{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"}
<style>body { margin: 8px; }</style>
{/if}
<div class="poll">
<h4>{$title}</h4>
<form onsubmit="pollFormSubmit(event, this)" data-multi="{$multiple ? '1' : '0'}" data-pid="{$id}">
<div class="poll-options">
<div n:foreach="$options as $oid => $option" class="poll-option">
<label>
{if $multiple}
<input n:attr="disabled: is_null($thisUser)" type="checkbox" name="option{$oid}" onclick="pollCheckBoxPressed(this)" />
{else}
<input n:attr="disabled: is_null($thisUser)" type="radio" value="{$oid}" name="vote" onclick="pollRadioPressed(this)" />
{/if}
{$option}
</label>
</div>
</div>
{if $multiple}
<br/>
<input type="submit" class="button" value="{_cast_vote}" disabled="disabled" />
{/if}
</form>
<div class="poll-meta">
{tr("poll_voter_count", $votes)|noescape}<br/>
<span class="nobold">{$meta}</span>
</div>
</div>

View file

@ -0,0 +1,44 @@
{if !isset($parentModule) || substr($parentModule, 0, 21) === 'libchandler:absolute.'}
<link rel="shortcut icon" href="/assets/packages/static/openvk/img/icon.ico" />
<meta n:ifset="$csrfToken" name="csrf" value="{$csrfToken}" />
<script src="/language/{getLanguage()}.js" crossorigin="anonymous"></script>
{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"}
<style>body { margin: 8px; } .poll { border: 1px solid #e3e3e3; }</style>
{/if}
<div class="poll" data-id="{$id}">
<a n:if="$unlocked && $voted" href="javascript:pollRetractVote({$id})" class="poll-retract-vote">{_retract_vote}</a>
<h4>{$title}</h4>
<div class="poll-results">
<div n:foreach="$results->options as $option" class="poll-result">
{if $isAnon}
<a href="javascript:false">{$option->name}</a>
{else}
<a href="/poll{$id}/voters?option={base_convert($option->id, 10, 32)}">{$option->name}</a>
{/if}
<div class="poll-result-barspace">
<div class="poll-result-bar">
<span class="poll-result-count">{$option->votes}</span>
<div class="poll-result-bar-sub" style="width: {$option->pct}%">&nbsp;</div>
</div>
<div class="poll-result-pct">
<strong>{$option->pct}%</strong>
</div>
</div>
</div>
</div>
<div class="poll-meta">
{tr("poll_voter_count", $votes)|noescape}<br/>
<span class="nobold">{$meta}</span>
</div>
</div>

View file

@ -0,0 +1,40 @@
{extends "../@listView.xml"}
{block title}
{_poll_voters_list}
{/block}
{block header}
{_poll_voters_list} »
{$option[1]}
{/block}
{block tabs}
<div n:foreach="$options as $optionId => $optionName" class="tab" id="{$optionId == $option[0] ? 'activetabs' : 'ki'}">
<a id="{$optionId == $option[0] ? 'act_tab_a' : ''}" href="/poll{$pollId}/voters?option={$optionId}">{$optionName}</a>
</div>
{/block}
{* BEGIN ELEMENTS DESCRIPTION *}
{block link|strip|stripHtml}
{$x->getURL()}
{/block}
{block preview}
<img src="{$x->getAvatarUrl('miniscule')}" width="75" alt="Фотография пользователя" />
{/block}
{block name}
{$x->getCanonicalName()}
<img n:if="$x->isVerified()"
class="name-checkmark"
src="/assets/packages/static/openvk/img/checkmark.png"
/>
{/block}
{block description}
{/block}
{block actions}
{/block}

View file

@ -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}
<link rel="stylesheet" href="/themepack/{$theme->getId()}/{$theme->getVersion()}/stylesheet/styles.css" />
{if $isXmas}
<link rel="stylesheet" href="/themepack/{$theme->getId()}/{$theme->getVersion()}/resource/xmas.css" />
{/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}

View file

@ -11,6 +11,8 @@
{/if} {/if}
{elseif $attachment instanceof \openvk\Web\Models\Entities\Video} {elseif $attachment instanceof \openvk\Web\Models\Entities\Video}
<video class="media" src="{$attachment->getURL()}" controls="controls"></video> <video class="media" src="{$attachment->getURL()}" controls="controls"></video>
{elseif $attachment instanceof \openvk\Web\Models\Entities\Poll}
{presenter "openvk!Poll->view", $attachment->getId()}
{elseif $attachment instanceof \openvk\Web\Models\Entities\Post} {elseif $attachment instanceof \openvk\Web\Models\Entities\Post}
{php $GLOBALS["_nesAttGloCou"] = (isset($GLOBALS["_nesAttGloCou"]) ? $GLOBALS["_nesAttGloCou"] : 0) + 1} {php $GLOBALS["_nesAttGloCou"] = (isset($GLOBALS["_nesAttGloCou"]) ? $GLOBALS["_nesAttGloCou"] : 0) + 1}
{if $GLOBALS["_nesAttGloCou"] > 2} {if $GLOBALS["_nesAttGloCou"] > 2}

View file

@ -11,6 +11,9 @@
<div class="post-upload"> <div class="post-upload">
{_attachment}: <span>(unknown)</span> {_attachment}: <span>(unknown)</span>
</div> </div>
<div class="post-has-poll">
{_poll}
</div>
<div n:if="$postOpts ?? true" class="post-opts"> <div n:if="$postOpts ?? true" class="post-opts">
{var $anonEnabled = OPENVK_ROOT_CONF['openvk']['preferences']['wall']['anonymousPosting']['enable']} {var $anonEnabled = OPENVK_ROOT_CONF['openvk']['preferences']['wall']['anonymousPosting']['enable']}
@ -50,6 +53,7 @@
</div> </div>
<input type="file" class="postFileSel" id="postFilePic" name="_pic_attachment" accept="image/*" style="display:none;" /> <input type="file" class="postFileSel" id="postFilePic" name="_pic_attachment" accept="image/*" style="display:none;" />
<input type="file" class="postFileSel" id="postFileVid" name="_vid_attachment" accept="video/*" style="display:none;" /> <input type="file" class="postFileSel" id="postFileVid" name="_vid_attachment" accept="video/*" style="display:none;" />
<input type="hidden" name="poll" value="none" />
<input type="hidden" name="type" value="1" /> <input type="hidden" name="type" value="1" />
<input type="hidden" name="hash" value="{$csrfToken}" /> <input type="hidden" name="hash" value="{$csrfToken}" />
<br/> <br/>
@ -75,6 +79,10 @@
<img src="/assets/packages/static/openvk/img/oxygen-icons/16x16/actions/draw-brush.png" /> <img src="/assets/packages/static/openvk/img/oxygen-icons/16x16/actions/draw-brush.png" />
{_graffiti} {_graffiti}
</a> </a>
<a n:if="$polls ?? false" href="javascript:initPoll({$textAreaId})">
<img src="/assets/packages/static/openvk/img/oxygen-icons/16x16/actions/office-chart-bar-stacked.png" />
{_poll}
</a>
</div> </div>
</div> </div>
</div> </div>

View file

@ -8,7 +8,7 @@
</div> </div>
<div> <div>
<div n:if="$canPost" class="content_subtitle"> <div n:if="$canPost" class="content_subtitle">
{include "../components/textArea.xml", route => "/wall$owner/makePost", graffiti => true} {include "../components/textArea.xml", route => "/wall$owner/makePost", graffiti => true, polls => true}
</div> </div>
<div class="content"> <div class="content">

View file

@ -23,9 +23,11 @@ services:
- openvk\Web\Presenters\AppsPresenter - openvk\Web\Presenters\AppsPresenter
- openvk\Web\Presenters\ThemepacksPresenter - openvk\Web\Presenters\ThemepacksPresenter
- openvk\Web\Presenters\VKAPIPresenter - openvk\Web\Presenters\VKAPIPresenter
- openvk\Web\Presenters\PollPresenter
- openvk\Web\Presenters\BannedLinkPresenter - openvk\Web\Presenters\BannedLinkPresenter
- openvk\Web\Models\Repositories\Users - openvk\Web\Models\Repositories\Users
- openvk\Web\Models\Repositories\Posts - openvk\Web\Models\Repositories\Posts
- openvk\Web\Models\Repositories\Polls
- openvk\Web\Models\Repositories\Photos - openvk\Web\Models\Repositories\Photos
- openvk\Web\Models\Repositories\Albums - openvk\Web\Models\Repositories\Albums
- openvk\Web\Models\Repositories\Clubs - openvk\Web\Models\Repositories\Clubs

View file

@ -273,6 +273,10 @@ routes:
handler: "Apps->edit" handler: "Apps->edit"
- url: "/apps/uninstall" - url: "/apps/uninstall"
handler: "Apps->unInstall" handler: "Apps->unInstall"
- url: "/poll{num}"
handler: "Poll->view"
- url: "/poll{num}/voters"
handler: "Poll->voters"
- url: "/admin" - url: "/admin"
handler: "Admin->index" handler: "Admin->index"
- url: "/admin/users" - url: "/admin/users"

View file

@ -448,6 +448,15 @@ table {
color: #e8e8e8; color: #e8e8e8;
} }
.button[disabled] {
filter: opacity(0.5);
cursor: not-allowed;
}
.button[disabled]:hover {
color: #fff;
}
.button-loading { .button-loading {
display: inline-block; display: inline-block;
background-image: url('/assets/packages/static/openvk/img/loading_mini.gif'); background-image: url('/assets/packages/static/openvk/img/loading_mini.gif');
@ -801,6 +810,8 @@ table.User {
padding: 0 10px; padding: 0 10px;
margin-left: -10px; margin-left: -10px;
width: 607px; width: 607px;
overflow-x: auto;
white-space: nowrap;
} }
.tabs.stupid-fix { .tabs.stupid-fix {
@ -1338,14 +1349,14 @@ body.scrolled .toTop:hover {
font-weight: bold; font-weight: bold;
} }
.post-upload { .post-upload, .post-has-poll {
margin-top: 11px; margin-top: 11px;
margin-left: 3px; margin-left: 3px;
color: #3c3c3c; color: #3c3c3c;
display: none; display: none;
} }
.post-upload::before { .post-upload::before, .post-has-poll::before {
content: " "; content: " ";
width: 8px; width: 8px;
height: 8px; height: 8px;
@ -2051,6 +2062,81 @@ table td[width="120"] {
max-height: 250px; 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"] { .tippy-box[data-theme~="vk"] {
user-select: none; user-select: none;
background-color: #fff; background-color: #fff;

151
Web/static/js/al_polls.js Normal file
View file

@ -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 = `
<div id="poll_editor${id}">
<input type="text" name="title" placeholder="${tr("poll_title")}" />
<div class="poll-options" style="margin-top: 10px;"></div>
<input type="text" name="newOption" placeholder="${tr("poll_add_option")}" style="margin: 5px 0;" />
<hr/>
<label><input type="checkbox" name="anon" /> ${tr("poll_anonymous")}</label><br/>
<label><input type="checkbox" name="multi" /> ${tr("poll_multiple")}</label><br/>
<label><input type="checkbox" name="locked" /> ${tr("poll_locked")}</label><br/>
<label style="display: none;">
<input type="checkbox" name="expires" />
${tr("poll_edit_expires")}
<select name="expires_in" style="width: unset;">
${[...Array(32).keys()].reduce((p, c) => (!p ? '' : p) + ("<option value='" + c + "'>" + c + " " + tr("poll_edit_expires_days") + "</option>\n"))}
</select>
</label>
<div class="nobold" style="margin: 10px 5px 0">${tr("poll_editor_tips")}</div>
</div>
`;
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 += `<option>${$(this).val()}</option>`;
});
let xml = `
<Poll title="${title}" anonymous="${anon}" multiple="${multi}" locked="${lock}" duration="${expires}">
<options>${options}</options>
</Poll>
`;
$("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 = $('<input type="text" class="poll-option" style="margin: 5px 0;" />');
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();
});
});
}

View file

@ -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;

View file

@ -876,6 +876,38 @@
"messages_error_1" = "Message not delivered"; "messages_error_1" = "Message not delivered";
"messages_error_1_description" = "There was a general error in sending this message..."; "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 <b>the first one</b> to vote!";
"poll_voter_count_one" = "<b>Only one</b> user voted.";
"poll_voter_count_few" = "<b>$1</b> users voted.";
"poll_voter_count_many" = "<b>$1</b> users voted.";
"poll_voter_count_other" = "<b>$1</b> 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" = "Discussions"; "discussions" = "Discussions";

View file

@ -920,6 +920,38 @@
"messages_error_1" = "Сообщение не доставлено"; "messages_error_1" = "Сообщение не доставлено";
"messages_error_1_description" = "При отправке этого сообщения произошла ошибка общего характера..."; "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" = "Будьте <b>первым</b>, кто проголосует!";
"poll_voter_count_one" = "В опросе проголосовал <b>один</b> человек.";
"poll_voter_count_few" = "В опросе проголосовало <b>$1</b> человека.";
"poll_voter_count_many" = "В опросе проголосовало <b>$1</b> человек.";
"poll_voter_count_other" = "В опросе проголосовало <b>$1</b> человек.";
"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 */
"discussions" = "Обсуждения"; "discussions" = "Обсуждения";

View file

@ -44,3 +44,5 @@ comments.allow-graffiti: 0
# + Set this option to -1 if you want to disable the limit # + Set this option to -1 if you want to disable the limit
# + Set this option to any non-negative number to be this limit # + Set this option to any non-negative number to be this limit
wall.repost-liking-recursion-limit: 10 wall.repost-liking-recursion-limit: 10
polls.max-opts: 10