Merge pull request #112 from NicolasConstant/topic_polls

Topic polls
This commit is contained in:
Nicolas Constant 2019-06-13 21:33:42 -04:00 committed by GitHub
commit f126cf2b37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 607 additions and 8 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "sengi", "name": "sengi",
"version": "0.9.1", "version": "0.10.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"main": "main-electron.js", "main": "main-electron.js",
"description": "A multi-account desktop client for Mastodon and Pleroma", "description": "A multi-account desktop client for Mastodon and Pleroma",

View File

@ -60,6 +60,8 @@ import { AccountEmojiPipe } from './pipes/account-emoji.pipe';
import { CardComponent } from './components/stream/status/card/card.component'; import { CardComponent } from './components/stream/status/card/card.component';
import { ListEditorComponent } from './components/floating-column/manage-account/my-account/list-editor/list-editor.component'; import { ListEditorComponent } from './components/floating-column/manage-account/my-account/list-editor/list-editor.component';
import { ListAccountComponent } from './components/floating-column/manage-account/my-account/list-editor/list-account/list-account.component'; import { ListAccountComponent } from './components/floating-column/manage-account/my-account/list-editor/list-account/list-account.component';
import { PollComponent } from './components/stream/status/poll/poll.component';
import { TimeLeftPipe } from './pipes/time-left.pipe';
const routes: Routes = [ const routes: Routes = [
{ path: "", redirectTo: "home", pathMatch: "full" }, { path: "", redirectTo: "home", pathMatch: "full" },
@ -108,7 +110,9 @@ const routes: Routes = [
AccountEmojiPipe, AccountEmojiPipe,
CardComponent, CardComponent,
ListEditorComponent, ListEditorComponent,
ListAccountComponent ListAccountComponent,
PollComponent,
TimeLeftPipe
], ],
imports: [ imports: [
FontAwesomeModule, FontAwesomeModule,

View File

@ -154,6 +154,7 @@ class NotificationWrapper {
case 'mention': case 'mention':
case 'reblog': case 'reblog':
case 'favourite': case 'favourite':
case 'poll':
this.status= new StatusWrapper(notification.status, provider); this.status= new StatusWrapper(notification.status, provider);
break; break;
} }
@ -164,5 +165,5 @@ class NotificationWrapper {
wrapperId: string; wrapperId: string;
account: Account; account: Account;
status: StatusWrapper; status: StatusWrapper;
type: 'mention' | 'reblog' | 'favourite' | 'follow'; type: 'mention' | 'reblog' | 'favourite' | 'follow' | 'poll';
} }

View File

@ -0,0 +1,29 @@
<div class="poll">
<div *ngIf="!poll.voted && !poll.expired">
<div *ngFor="let o of options">
<label class="poll__container">{{o.title}}
<input class="poll__container__input" type="{{choiceType}}" name="{{pollName}}" value="{{o.title}}"
(change)="onSelectionChange(o)">
<span class="poll__container__checkmark" *ngIf="!pollLocked"
[ngClass]="{'poll__container__checkmark--box' : choiceType=='checkbox', 'poll__container__checkmark--round': choiceType=='radio'}"></span>
<fa-icon *ngIf="pollLocked" class="poll__container__lock" title="Account can't access this poll" [icon]="faLock"></fa-icon>
</label>
</div>
</div>
<div *ngIf="poll.voted || poll.expired">
<div *ngFor="let o of options">
<div class="poll__result" title="{{ o.votes_count }} votes">
<div class="poll__result--progress-bar" [style.width]="o.percentage + '%'" [ngClass]="{ 'poll__result--progress-bar--most-votes': o.isMax }"></div>
<div class="poll__result--data"> <span class="poll__result--percentage">{{ o.percentage }}%</span>
{{o.title}}</div>
</div>
</div>
</div>
<div class="poll__voting">
<button href *ngIf="!poll.voted && !poll.expired && !pollLocked" class="btn btn-sm btn-custom-primary poll__btn-vote"
title="don't boo, vote!" (click)="vote()">Vote</button>
<a href class="poll__refresh" *ngIf="(poll.voted || poll.expired) && !pollLocked" title="refresh poll" (click)="refresh()">refresh</a>
<div class="poll__statistics"><span *ngIf="(poll.voted || poll.expired) && !pollLocked" class="poll__separator">·</span>{{poll.votes_count}} votes<span *ngIf="!poll.expired" class="poll__separator" title="{{ poll.expires_at | timeLeft | async }}">· {{ poll.expires_at | timeLeft | async }}</span></div>
</div>
</div>

View File

@ -0,0 +1,184 @@
// @import "variables";
// @import "commons";
// @import "panel";
@import "buttons";
.poll {
color: white;
color: rgb(228, 228, 228);
&__btn-vote {
margin: 0 10px 0 0;
padding: 4px 15px;
}
&__statistics {
display: inline-block;
font-size: 0.8em;
color: rgb(101, 121, 160);
}
&__label {
cursor: pointer;
}
&__voting {
margin-top: 10px;
}
&__refresh {
font-size: 0.8em;
color: rgb(101, 121, 160);
}
&__container {
display: block;
position: relative;
padding-left: 25px;
margin: 0 0 5px 5px;
cursor: pointer;
// font-size: 22px;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
&:hover &__input~&__checkmark {
background-color: #ccc;
}
& &__input:checked~&__checkmark {
background-color: rgb(62, 75, 100);
}
&__input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
&__lock {
position: absolute;
top: 0;
left: 0;
}
&__checkmark--box {
position: absolute;
top: 0;
left: 0;
height: 20px;
width: 20px;
background-color: #eee;
&:after {
content: "";
position: absolute;
display: none;
}
}
&__checkmark--round {
position: absolute;
top: 0;
left: 0;
height: 20px;
width: 20px;
background-color: #eee;
border-radius: 50%;
}
& &__input:checked:checked~&__checkmark:after {
display: block;
}
& &__checkmark--box:after {
left: 7px;
top: 5px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 3px 3px 0;
-webkit-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
&__checkmark--round:after {
content: "";
position: absolute;
display: none;
}
& &__checkmark--round:after {
top: 6px;
left: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background: white;
}
}
&__result {
transition: width 2s;
margin: 0 0 5px 5px;
padding: 0 5px 0 5px;
position: relative;
height: 27px;
&--data {
position: absolute;
z-index: 10;
}
&--percentage {
color: rgb(228, 228, 228);
color: white;
display: inline;
margin-right: 10px;
}
&--progress-bar {
position: absolute;
background-color: rgb(47, 68, 100);
// background-color: rgb(43, 62, 92);
top:0;
left:0;
width: calc(100%);
height: 22px;
z-index: 1;
float: left;
border-radius: 2px;
&--most-votes {
background-color: rgb(18, 118, 158);
}
}
}
&__separator {
display: inline-block;
margin: 0 5px;
}
}
.noselect {
-webkit-touch-callout: none;
/* iOS Safari */
-webkit-user-select: none;
/* Safari */
-khtml-user-select: none;
/* Konqueror HTML */
-moz-user-select: none;
/* Firefox */
-ms-user-select: none;
/* Internet Explorer/Edge */
user-select: none;
/* Non-prefixed version, currently supported by Chrome and Opera */
}

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { PollComponent } from './poll.component';
xdescribe('PollComponent', () => {
let component: PollComponent;
let fixture: ComponentFixture<PollComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ PollComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PollComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,191 @@
import { Component, OnInit, Input } from '@angular/core';
import { Store } from '@ngxs/store';
import { Observable, Subscription } from 'rxjs';
import { faLock } from "@fortawesome/free-solid-svg-icons";
import { Poll, PollOption, Status } from '../../../../services/models/mastodon.interfaces';
import { AccountInfo } from '../../../../states/accounts.state';
import { MastodonService } from '../../../../services/mastodon.service';
import { NotificationService } from '../../../../services/notification.service';
import { ToolsService } from '../../../../services/tools.service';
import { StatusWrapper } from '../../../../models/common.model';
@Component({
selector: 'app-poll',
templateUrl: './poll.component.html',
styleUrls: ['./poll.component.scss']
})
export class PollComponent implements OnInit {
faLock = faLock;
pollName: string;
choiceType: string;
pollLocked: boolean;
private pollSelection: number[] = [];
options: PollOptionWrapper[] = [];
private pollPerAccountId: { [id: string]: Promise<Poll>; } = {};
private _poll: Poll;
@Input('poll')
set poll(value: Poll) {
this._poll = value;
this.pollName = this.poll.id;
if (this.poll.multiple) {
this.choiceType = 'checkbox';
} else {
this.choiceType = 'radio';
}
this.options.length = 0;
const maxVotes = Math.max(...this.poll.options.map(x => x.votes_count));
let i = 0;
for (let opt of this.poll.options) {
let optWrapper = new PollOptionWrapper(i, opt, this.poll.votes_count, opt.votes_count === maxVotes);
this.options.push(optWrapper);
i++;
}
}
get poll(): Poll {
return this._poll;
}
@Input() provider: AccountInfo;
@Input() status: Status;
private accounts$: Observable<AccountInfo[]>;
private accountSub: Subscription;
private selectedAccount: AccountInfo;
constructor(
private readonly store: Store,
private notificationService: NotificationService,
private toolsService: ToolsService,
private mastodonService: MastodonService) {
this.accounts$ = this.store.select(state => state.registeredaccounts.accounts);
}
ngOnInit() {
this.pollPerAccountId[this.provider.id] = Promise.resolve(this.poll);
this.selectedAccount = this.provider;
this.accountSub = this.accounts$.subscribe((accounts: AccountInfo[]) => {
this.checkStatus(accounts);
});
}
private checkStatus(accounts: AccountInfo[]): void {
this.pollLocked = false;
var newSelectedAccount = accounts.find(x => x.isSelected);
const accountChanged = this.selectedAccount.id !== newSelectedAccount.id;
if (accountChanged && !this.pollPerAccountId[newSelectedAccount.id] && (this.status.visibility === 'public' || this.status.visibility === 'unlisted')) {
this.setStatsAtZero();
this.pollPerAccountId[newSelectedAccount.id] = this.toolsService.getStatusUsableByAccount(newSelectedAccount, new StatusWrapper(this.status, this.provider))
.then((status: Status) => {
return this.mastodonService.getPoll(newSelectedAccount, status.poll.id);
})
.then((poll: Poll) => {
this.poll = poll;
return poll;
})
.catch(err => {
this.notificationService.notifyHttpError(err);
return null;
});
} else if (this.status.visibility !== 'public' && this.status.visibility !== 'unlisted' && this.provider.id !== newSelectedAccount.id) {
this.pollLocked = true;
} else {
this.pollPerAccountId[newSelectedAccount.id]
.then((poll: Poll) => {
this.poll = poll;
})
.catch(err => this.notificationService.notifyHttpError(err));
}
this.selectedAccount = newSelectedAccount;
}
vote(): boolean {
const selectedAccount = this.selectedAccount;
const pollPromise = this.pollPerAccountId[selectedAccount.id];
pollPromise
.then((poll: Poll) => {
return this.mastodonService.voteOnPoll(selectedAccount, poll.id, this.pollSelection);
})
.then((poll: Poll) => {
this.poll = poll;
this.pollPerAccountId[selectedAccount.id] = Promise.resolve(poll);
})
.catch(err => this.notificationService.notifyHttpError(err));
return false;
}
private setStatsAtZero() {
this.options.forEach(p => {
p.votes_count = 0;
p.percentage = '0';
});
}
refresh(): boolean {
this.setStatsAtZero();
const selectedAccount = this.selectedAccount;
const pollPromise = this.pollPerAccountId[selectedAccount.id];
pollPromise
.then((poll: Poll) => {
return this.mastodonService.getPoll(selectedAccount, poll.id);
})
.then((poll: Poll) => {
this.poll = poll;
this.pollPerAccountId[selectedAccount.id] = Promise.resolve(poll);
})
.catch(err => this.notificationService.notifyHttpError(err));
return false;
}
onSelectionChange(entry: PollOptionWrapper) {
let index = entry.id;
if (this.poll.multiple) {
if (this.pollSelection.includes(index)) {
this.pollSelection = this.pollSelection.filter(x => x !== index);
} else {
this.pollSelection.push(index);
}
} else {
this.pollSelection.length = 0;
this.pollSelection.push(index);
}
}
}
class PollOptionWrapper implements PollOption {
constructor(index: number, option: PollOption, totalVotes: number, isMax: boolean) {
this.id = index;
this.title = option.title;
this.votes_count = option.votes_count;
if (totalVotes === 0) {
this.percentage = '0';
} else {
this.percentage = ((this.votes_count / totalVotes) * 100).toFixed(0);
}
this.isMax = isMax;
}
id: number;
title: string;
votes_count: number;
percentage: string;
isMax: boolean;
}

View File

@ -21,6 +21,14 @@
innerHTML="{{ notificationAccount | accountEmoji }}"></a> boosted your status innerHTML="{{ notificationAccount | accountEmoji }}"></a> boosted your status
</div> </div>
</div> </div>
<div *ngIf="notificationType === 'poll'">
<div class="notification--icon">
<fa-icon class="boost" [icon]="faList"></fa-icon>
</div>
<div class="notification--label">
A poll you have voted in has ended
</div>
</div>
<div class="status"> <div class="status">
<div [ngClass]="{'notification--status': notificationAccount }"> <div [ngClass]="{'notification--status': notificationAccount }">
<a href class="status__profile-link" title="{{displayedStatus.account.acct}}" <a href class="status__profile-link" title="{{displayedStatus.account.acct}}"
@ -67,6 +75,9 @@
(accountSelected)="accountSelected($event)" (hashtagSelected)="hashtagSelected($event)" (accountSelected)="accountSelected($event)" (hashtagSelected)="hashtagSelected($event)"
(textSelected)="textSelected()"></app-databinded-text> (textSelected)="textSelected()"></app-databinded-text>
<app-poll class="status__poll" *ngIf="!isContentWarned && displayedStatus.poll"
[poll]="displayedStatus.poll" [status]="displayedStatus" [provider]="statusWrapper.provider"></app-poll>
<app-card class="status__card" *ngIf="!isContentWarned && displayedStatus.card && !hasAttachments" [card]="displayedStatus.card"></app-card> <app-card class="status__card" *ngIf="!isContentWarned && displayedStatus.card && !hasAttachments" [card]="displayedStatus.card"></app-card>
<app-attachements *ngIf="!isContentWarned && hasAttachments" class="attachments" <app-attachements *ngIf="!isContentWarned && hasAttachments" class="attachments"

View File

@ -116,6 +116,11 @@
margin: 10px 10px 0 $avatar-column-space; margin: 10px 10px 0 $avatar-column-space;
display: block; display: block;
} }
&__poll {
word-wrap: break-word;
margin: 10px 10px 0 $avatar-column-space;
display: block;
}
&__content-warning { &__content-warning {
min-height: 80px; min-height: 80px;
display: block; // border: 1px solid greenyellow; display: block; // border: 1px solid greenyellow;

View File

@ -1,5 +1,5 @@
import { Component, OnInit, Input, Output, EventEmitter, ViewChild } from "@angular/core"; import { Component, OnInit, Input, Output, EventEmitter, ViewChild } from "@angular/core";
import { faStar, faRetweet } from "@fortawesome/free-solid-svg-icons"; import { faStar, faRetweet, faList } from "@fortawesome/free-solid-svg-icons";
import { Status, Account } from "../../../services/models/mastodon.interfaces"; import { Status, Account } from "../../../services/models/mastodon.interfaces";
import { OpenThreadEvent, ToolsService } from "../../../services/tools.service"; import { OpenThreadEvent, ToolsService } from "../../../services/tools.service";
@ -17,6 +17,7 @@ export class StatusComponent implements OnInit {
faStar = faStar; faStar = faStar;
faRetweet = faRetweet; faRetweet = faRetweet;
faList = faList;
displayedStatus: Status; displayedStatus: Status;
@ -39,7 +40,7 @@ export class StatusComponent implements OnInit {
@Input() isThreadDisplay: boolean; @Input() isThreadDisplay: boolean;
@Input() notificationType: 'mention' | 'reblog' | 'favourite'; @Input() notificationType: 'mention' | 'reblog' | 'favourite' | 'poll';
@Input() notificationAccount: Account; @Input() notificationAccount: Account;
private _statusWrapper: StatusWrapper; private _statusWrapper: StatusWrapper;

View File

@ -0,0 +1,8 @@
import { TimeLeftPipe } from './time-left.pipe';
xdescribe('TimeLeftPipe', () => {
it('create an instance', () => {
const pipe = new TimeLeftPipe(null);
expect(pipe).toBeTruthy();
});
});

View File

@ -0,0 +1,106 @@
import { Pipe, PipeTransform, NgZone } from '@angular/core';
import { Observable, Observer } from 'rxjs';
interface processOutput {
text: string; // Convert timestamp to string
timeToUpdate: number; // Time until update in milliseconds
}
@Pipe({
name: 'timeLeft'
})
export class TimeLeftPipe implements PipeTransform {
constructor(private ngZone: NgZone) { }
private process = (timestamp: number): processOutput => {
let text: string;
let timeToUpdate: number;
const now = new Date();
// Time ago in milliseconds
const timeLeft: number = timestamp - now.getTime();
const seconds = timeLeft / 1000;
const minutes = seconds / 60;
const hours = minutes / 60;
const days = hours / 24;
// const months = days / 30.416;
// const years = days / 365;
if (seconds < 0) {
text = '0 seconds left';
} else if (seconds <= 60) {
text = Math.round(seconds) + ' seconds left';
} else if (minutes <= 90) {
text = Math.round(minutes) + ' minutes left';
} else if (hours <= 24) {
text = Math.round(hours) + ' hours left';
} else {
text = Math.round(days) + ' days left';
}
if (seconds < -10) {
// update every week
timeToUpdate = 7 * 24 * 3600 * 1000;
} else if (minutes < 1) {
// update every 2 secs
timeToUpdate = 2 * 1000;
} else if (hours < 1) {
// update every 30 secs
timeToUpdate = 30 * 1000;
} else if (days < 1) {
// update every 5 mins
timeToUpdate = 300 * 1000;
} else {
// update every hour
timeToUpdate = 3600 * 1000;
}
return {
text,
timeToUpdate
};
}
public transform = (value: string | Date): Observable<string> => {
let d: Date;
if (value instanceof Date) {
d = value;
}
else {
d = new Date(value);
}
// time value in milliseconds
const timestamp = d.getTime();
let timeoutID: any;
return Observable.create((observer: Observer<string>) => {
let latestText = '';
// Repeatedly set new timeouts for new update checks.
const registerUpdate = () => {
const processOutput = this.process(timestamp);
if (processOutput.text !== latestText) {
latestText = processOutput.text;
this.ngZone.run(() => {
observer.next(latestText);
});
}
timeoutID = setTimeout(registerUpdate, processOutput.timeToUpdate);
};
this.ngZone.runOutsideAngular(registerUpdate);
// Return teardown function
const teardownFunction = () => {
if (timeoutID) {
clearTimeout(timeoutID);
}
};
return teardownFunction;
});
}
}

View File

@ -2,12 +2,12 @@ import { Injectable } from '@angular/core';
import { HttpHeaders, HttpClient, HttpResponse } from '@angular/common/http'; import { HttpHeaders, HttpClient, HttpResponse } from '@angular/common/http';
import { ApiRoutes } from './models/api.settings'; import { ApiRoutes } from './models/api.settings';
import { Account, Status, Results, Context, Relationship, Instance, Attachment, Notification, List } from "./models/mastodon.interfaces"; import { Account, Status, Results, Context, Relationship, Instance, Attachment, Notification, List, Poll } from "./models/mastodon.interfaces";
import { AccountInfo } from '../states/accounts.state'; import { AccountInfo } from '../states/accounts.state';
import { StreamTypeEnum, StreamElement } from '../states/streams.state'; import { StreamTypeEnum, StreamElement } from '../states/streams.state';
@Injectable() @Injectable()
export class MastodonService { export class MastodonService {
private apiRoutes = new ApiRoutes(); private apiRoutes = new ApiRoutes();
constructor(private readonly httpClient: HttpClient) { } constructor(private readonly httpClient: HttpClient) { }
@ -306,6 +306,20 @@ export class MastodonService {
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` }); const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.delete(route, { headers: headers }).toPromise(); return this.httpClient.delete(route, { headers: headers }).toPromise();
} }
voteOnPoll(account: AccountInfo, pollId: string, pollSelection: number[]): Promise<Poll> {
let route = `https://${account.instance}${this.apiRoutes.voteOnPoll}`.replace('{0}', pollId);
route += `?${this.formatArray(pollSelection.map(x => x.toString()), 'choices')}`;
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.post<Poll>(route, null, { headers: headers }).toPromise();
}
getPoll(account: AccountInfo, pollId: string): Promise<Poll> {
let route = `https://${account.instance}${this.apiRoutes.getPoll}`.replace('{0}', pollId);
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.get<Poll>(route, { headers: headers }).toPromise();
}
} }
export enum VisibilityEnum { export enum VisibilityEnum {

View File

@ -59,4 +59,6 @@ export class ApiRoutes {
deleteList = '/api/v1/lists/{0}'; deleteList = '/api/v1/lists/{0}';
addAccountToList = '/api/v1/lists/{0}/accounts'; addAccountToList = '/api/v1/lists/{0}/accounts';
removeAccountFromList = '/api/v1/lists/{0}/accounts'; removeAccountFromList = '/api/v1/lists/{0}/accounts';
voteOnPoll = '/api/v1/polls/{0}/votes';
getPoll = '/api/v1/polls/{0}';
} }

View File

@ -1,3 +1,5 @@
import { PlatformLocation } from '@angular/common';
export interface AppData { export interface AppData {
client_id: string; client_id: string;
client_secret: string; client_secret: string;
@ -122,7 +124,7 @@ export interface Mention {
export interface Notification { export interface Notification {
id: string; id: string;
type: 'mention' | 'reblog' | 'favourite' | 'follow'; type: 'mention' | 'reblog' | 'favourite' | 'follow' | 'poll';
created_at: string; created_at: string;
account: Account; account: Account;
status?: Status; status?: Status;
@ -174,6 +176,7 @@ export interface Status {
language: string; language: string;
pinned: boolean; pinned: boolean;
card: Card; card: Card;
poll: Poll;
pleroma: PleromaStatusInfo; pleroma: PleromaStatusInfo;
} }
@ -191,4 +194,19 @@ export interface Tag {
export interface List { export interface List {
id: string; id: string;
title: string; title: string;
}
export interface Poll {
id: string;
expires_at: string;
expired: boolean;
multiple: boolean;
votes_count: number;
options: PollOption[];
voted: boolean;
}
export interface PollOption {
title: string;
votes_count: number;
} }