Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/styles/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
@use 'components/inline_md';
@use 'components/emoji_picker';
@use 'components/filter_list';
@use 'components/poll';
@use 'pages/post_single';
@use 'pages/post_front';
@use 'pages/page_bookmarks';
Expand Down
24 changes: 24 additions & 0 deletions assets/styles/components/_poll.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
.poll {
margin: auto;

form {
margin: auto;
max-width: 400px;
}

.poll-result {
margin: .5rem 0;
}

.choice-result {
margin-bottom: .5rem;

.choice-bar {
background-color: var(--kbin-button-primary-bg);
color: var(--kbin-button-primary-text-color);
height: .5rem;
border: var(--kbin-button-primary-border);
border-radius: var(--kbin-rounded-edges-radius);
}
}
}
4 changes: 4 additions & 0 deletions assets/styles/components/_post.scss
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
div {
margin-bottom: 0;
}

.poll-area div {
margin-bottom: 1rem;
}
}

.post-container {
Expand Down
18 changes: 17 additions & 1 deletion assets/styles/layout/_forms.scss
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,8 @@ form {
flex-direction: row-reverse;
justify-content: flex-end;

input[type=checkbox] {
input[type=checkbox],
input[type=radio] {
margin-right: .5rem;
}
}
Expand Down Expand Up @@ -574,3 +575,18 @@ div.input-box {
width: 100%;

}

.poll-area {
.sub-form {
display: none;
}
&:has(input:checked) .sub-form {
display: block;
}
}

form {
.collection-container.hide-index .existing-collection-items > div > label {
display: none;
}
}
6 changes: 6 additions & 0 deletions config/mbin_routes/activity_pub.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,9 @@ ap_contexts:
path: /contexts.{_format}
methods: [GET]
format: jsonld

ap_poll_vote:
controller: App\Controller\ActivityPub\PollVoteController
path: /u/{username}/votes/{uuid}
methods: [GET]
condition: '%kbin_ap_route_condition%'
9 changes: 9 additions & 0 deletions config/mbin_routes/poll.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
poll_vote:
controller: App\Controller\PollVoteController::vote
path: /poll/{id}/vote
methods: [GET]

poll_refresh:
controller: App\Controller\PollVoteController::refreshVoteCounts
path: /poll/{id}/refresh
methods: [GET]
19 changes: 19 additions & 0 deletions config/mbin_routes/poll_api.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
api_poll_vote_entry:
controller: App\Controller\Api\Poll\PollVoteController::voteOnEntry
path: /api/entry/{entryId}/poll/vote
methods: [ PUT ]

api_poll_vote_entry_comment:
controller: App\Controller\Api\Poll\PollVoteController::voteOnEntryComment
path: /api/entry/{entryId}/comments/{commentId}/poll/vote
methods: [ PUT ]

api_poll_vote_post:
controller: App\Controller\Api\Poll\PollVoteController::voteOnPost
path: /api/post/{postId}/poll/vote
methods: [ PUT ]

api_poll_vote_post_comment:
controller: App\Controller\Api\Poll\PollVoteController::voteOnPostComment
path: /api/post/{postId}/comments/{commentId}/poll/vote
methods: [ PUT ]
24 changes: 24 additions & 0 deletions docs/05-fediverse_developers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,30 @@ Each magazine has an AP actor at `https://instance.tld/m/name`:
%object_message%
```

### Polls

Polls can be part of all of the above, except private messages. If that is the case, their `type` becomes `Question`.
The deciding factor if an object is a thread or microblog in that case is the existence of the `title` property.

Mbin's representation of polls are mostly the same as Mastodons (see [Mastodon's documentation](https://docs.joinmastodon.org/spec/activitypub/#Question)):

- Poll options are part of the `oneOf` (for single choice polls) or the `anyOf` property (for multiple choice polls)
- Each option has a `name` property, which is both the value and the label of this option
- Each option has a `replies` property containing the amount of votes in their `totalItems` property

```json
%object_poll%
```

#### Poll Votes

A vote in a poll is represented as a `Note` object with a `name` property containing the exact value of the choice that is voted for.
For multiple-choice-polls, multiple activities will be sent, each containing one vote for one choice.

```json
%object_poll_vote%
```

## Collections

### User Outbox
Expand Down
83 changes: 83 additions & 0 deletions migrations/Version20260408134939.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260408134939 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create initial poll table and relations';
}

public function up(Schema $schema): void
{
$this->addSql('CREATE SEQUENCE poll_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE poll_choice_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE poll (id INT NOT NULL, multiple_choice BOOLEAN NOT NULL, voter_count INT DEFAULT 0 NOT NULL, end_date TIMESTAMP(0) WITH TIME ZONE NOT NULL, is_remote BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, sent_notifications BOOLEAN NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE TABLE poll_choice (id INT NOT NULL, name VARCHAR(255) NOT NULL, vote_count INT NOT NULL, poll_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_2DAE19C93C947C0F ON poll_choice (poll_id)');
$this->addSql('CREATE TABLE poll_vote (uuid UUID NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, ap_id VARCHAR(255) DEFAULT NULL, voter_id INT NOT NULL, choice_id INT NOT NULL, poll_id INT NOT NULL, PRIMARY KEY (uuid))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_ED568EBE904F155E ON poll_vote (ap_id)');
$this->addSql('CREATE INDEX IDX_ED568EBEEBB4B8AD ON poll_vote (voter_id)');
$this->addSql('CREATE INDEX IDX_ED568EBE998666D1 ON poll_vote (choice_id)');
$this->addSql('CREATE INDEX IDX_ED568EBE3C947C0F ON poll_vote (poll_id)');
$this->addSql('ALTER TABLE poll_choice ADD CONSTRAINT FK_2DAE19C93C947C0F FOREIGN KEY (poll_id) REFERENCES poll (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE poll_vote ADD CONSTRAINT FK_ED568EBEEBB4B8AD FOREIGN KEY (voter_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE poll_vote ADD CONSTRAINT FK_ED568EBE998666D1 FOREIGN KEY (choice_id) REFERENCES poll_choice (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE poll_vote ADD CONSTRAINT FK_ED568EBE3C947C0F FOREIGN KEY (poll_id) REFERENCES poll (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE activity ADD object_poll_vote_id UUID DEFAULT NULL');
$this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095A69F3DEA4 FOREIGN KEY (object_poll_vote_id) REFERENCES poll_vote (uuid) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_AC74095A69F3DEA4 ON activity (object_poll_vote_id)');
$this->addSql('ALTER TABLE entry ADD poll_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE entry ADD CONSTRAINT FK_2B219D703C947C0F FOREIGN KEY (poll_id) REFERENCES poll (id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_2B219D703C947C0F ON entry (poll_id)');
$this->addSql('ALTER TABLE entry_comment ADD poll_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFB3C947C0F FOREIGN KEY (poll_id) REFERENCES poll (id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_B892FDFB3C947C0F ON entry_comment (poll_id)');
$this->addSql('ALTER TABLE post ADD poll_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE post ADD CONSTRAINT FK_5A8A6C8D3C947C0F FOREIGN KEY (poll_id) REFERENCES poll (id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_5A8A6C8D3C947C0F ON post (poll_id)');
$this->addSql('ALTER TABLE post_comment ADD poll_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE post_comment ADD CONSTRAINT FK_A99CE55F3C947C0F FOREIGN KEY (poll_id) REFERENCES poll (id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_A99CE55F3C947C0F ON post_comment (poll_id)');
$this->addSql('ALTER TABLE notification ADD poll_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA3C947C0F FOREIGN KEY (poll_id) REFERENCES poll (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_BF5476CA3C947C0F ON notification (poll_id)');
}

public function down(Schema $schema): void
{
$this->addSql('DROP SEQUENCE poll_id_seq CASCADE');
$this->addSql('DROP SEQUENCE poll_choice_id_seq CASCADE');
$this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095A69F3DEA4');
$this->addSql('DROP INDEX IDX_AC74095A69F3DEA4');
$this->addSql('ALTER TABLE activity DROP object_poll_vote_id');
$this->addSql('ALTER TABLE entry DROP CONSTRAINT FK_2B219D703C947C0F');
$this->addSql('DROP INDEX UNIQ_2B219D703C947C0F');
$this->addSql('ALTER TABLE entry DROP poll_id');
$this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFB3C947C0F');
$this->addSql('DROP INDEX UNIQ_B892FDFB3C947C0F');
$this->addSql('ALTER TABLE entry_comment DROP poll_id');
$this->addSql('ALTER TABLE post DROP CONSTRAINT FK_5A8A6C8D3C947C0F');
$this->addSql('DROP INDEX UNIQ_5A8A6C8D3C947C0F');
$this->addSql('ALTER TABLE post DROP poll_id');
$this->addSql('ALTER TABLE post_comment DROP CONSTRAINT FK_A99CE55F3C947C0F');
$this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA3C947C0F');
$this->addSql('DROP INDEX IDX_BF5476CA3C947C0F');
$this->addSql('ALTER TABLE notification DROP poll_id');
$this->addSql('DROP INDEX UNIQ_A99CE55F3C947C0F');
$this->addSql('ALTER TABLE post_comment DROP poll_id');
$this->addSql('ALTER TABLE poll_choice DROP CONSTRAINT FK_2DAE19C93C947C0F');
$this->addSql('ALTER TABLE poll_vote DROP CONSTRAINT FK_ED568EBEEBB4B8AD');
$this->addSql('ALTER TABLE poll_vote DROP CONSTRAINT FK_ED568EBE998666D1');
$this->addSql('ALTER TABLE poll_vote DROP CONSTRAINT FK_ED568EBE3C947C0F');
$this->addSql('DROP TABLE poll');
$this->addSql('DROP TABLE poll_choice');
$this->addSql('DROP TABLE poll_vote');
}
}
33 changes: 33 additions & 0 deletions src/Command/DebugCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace App\Command;

use App\Repository\PollRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand('mbin:debug')]
class DebugCommand extends Command
{
public function __construct(
private readonly PollRepository $pollRepository,
) {
parent::__construct();
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
foreach ($this->pollRepository->getAllEndedPollsToSentNotifications() as $poll) {
$output->writeln("poll {$poll->getId()}");
foreach ($this->pollRepository->getAllLocalVotersOfPoll($poll) as $voter) {
$output->writeln("Voter $voter->username in poll {$poll->getId()}");
}
}

return 0;
}
}
18 changes: 18 additions & 0 deletions src/Command/DocumentationGenerateFederationCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use App\Factory\ActivityPub\LockFactory;
use App\Factory\ActivityPub\MessageFactory;
use App\Factory\ActivityPub\PersonFactory;
use App\Factory\ActivityPub\PollVoteFactory;
use App\Factory\ActivityPub\PostCommentNoteFactory;
use App\Factory\ActivityPub\PostNoteFactory;
use App\Factory\ImageFactory;
Expand All @@ -44,6 +45,7 @@
use App\Service\EntryManager;
use App\Service\MagazineManager;
use App\Service\MessageManager;
use App\Service\PollManager;
use App\Service\PostCommentManager;
use App\Service\PostManager;
use App\Service\ReportManager;
Expand Down Expand Up @@ -103,6 +105,8 @@ public function __construct(
private readonly CollectionInfoWrapper $collectionInfoWrapper,
private readonly BlockFactory $blockFactory,
private readonly LockFactory $lockFactory,
private readonly PollVoteFactory $pollVoteFactory,
private readonly PollManager $pollManager,
) {
parent::__construct();
}
Expand Down Expand Up @@ -224,6 +228,18 @@ private function generateMarkdown(string $content): string
$thread = $this->messageManager->toThread($dto, $user, $user2);
$message = $thread->getLastMessage();

$dto = new EntryDto();
$dto->user = $user;
$dto->magazine = $magazine;
$dto->title = 'Do you like FOSS?';
$dto->lang = 'en';
$dto->addPoll = true;
$dto->choices = ['Yes', 'No'];
$dto->isMultipleChoicePoll = false;
$entryWithPoll = $this->entryManager->create($dto, $user, rateLimit: false);
$this->pollManager->vote($entryWithPoll->poll, $entryWithPoll, $user2, ['Yes']);
$pollVote = $entryWithPoll->poll->getUserVotes($user2)[0];

$userOutboxCollectionInfo = $this->collectionFactory->getUserOutboxCollection($user, false);
$userOutboxCollectionItems = $this->collectionFactory->getUserOutboxCollectionItems($user, 1, false);
$userFollowerCollection = $this->collectionInfoWrapper->build('ap_user_followers', ['username' => $user->username], $this->userRepository->findFollowers(1, $user)->getNbResults());
Expand Down Expand Up @@ -279,6 +295,8 @@ private function generateMarkdown(string $content): string
'%object_post%' => json_encode($this->postNoteFactory->create($post, []), $jsonFlags),
'%object_post_comment%' => json_encode($this->postCommentNoteFactory->create($postComment, []), $jsonFlags),
'%object_message%' => json_encode($this->messageFactory->build($message, false), $jsonFlags),
'%object_poll%' => json_encode($this->entryPageFactory->create($entryWithPoll, []), $jsonFlags),
'%object_poll_vote%' => json_encode($this->pollVoteFactory->build($pollVote), $jsonFlags),
'%collection_user_outbox%' => json_encode($userOutboxCollectionInfo, $jsonFlags),
'%collection_items_user_outbox%' => json_encode($userOutboxCollectionItems, $jsonFlags),
'%collection_user_followers%' => json_encode($userFollowerCollection, $jsonFlags),
Expand Down
33 changes: 33 additions & 0 deletions src/Controller/ActivityPub/PollVoteController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace App\Controller\ActivityPub;

use App\Controller\AbstractController;
use App\Entity\PollVote;
use App\Entity\User;
use App\Factory\ActivityPub\PollVoteFactory;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class PollVoteController extends AbstractController
{
public function __invoke(
Request $request,
PollVoteFactory $pollVoteFactory,
#[MapEntity(mapping: ['username' => 'username'])] User $user,
#[MapEntity(mapping: ['uuid' => 'uuid'])] PollVote $pollVote,
): JsonResponse {
if ($pollVote->getUser()->getId() !== $user->getId()) {
throw new NotFoundHttpException();
}

return new JsonResponse(
$pollVoteFactory->build($pollVote),
headers: ['Content-Type' => 'application/activity+json'],
);
}
}
Loading
Loading