Merge pull request #102 from NicolasConstant/topic_list-support

Topic list support
This commit is contained in:
Nicolas Constant 2019-05-22 21:51:11 -04:00 committed by GitHub
commit b7b84dae24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 740 additions and 76 deletions

View File

@ -28,7 +28,7 @@
<div class="header__download-box--description">
A FLOSS multi-account Mastodon and Pleroma desktop client<br />
Now available in Beta (v0.8.0)<br />
Now available in Beta (v0.9.0)<br />
<br />
</div>
@ -43,9 +43,9 @@
<br />
<h4 class="header__download-box--subtitle">Or download the desktop client:</h4>
<a href="https://github.com/NicolasConstant/sengi/releases/download/0.8.0/Sengi-0.8.0-win.exe" class="download-button" title="download client for windows"><i class="fab fa-windows"></i></a>
<a href="https://github.com/NicolasConstant/sengi/releases/download/0.8.0/Sengi-0.8.0-mac.dmg" class="download-button" title="download client for mac"><i class="fab fa-apple"></i></a>
<a href="https://github.com/NicolasConstant/sengi/releases/download/0.8.0/Sengi-0.8.0-linux.deb" class="download-button" title="download client for debian-based distrib"><i class="fab fa-ubuntu"></i></a>
<a href="https://github.com/NicolasConstant/sengi/releases/download/0.9.0/Sengi-0.9.0-win.exe" class="download-button" title="download client for windows"><i class="fab fa-windows"></i></a>
<a href="https://github.com/NicolasConstant/sengi/releases/download/0.9.0/Sengi-0.9.0-mac.dmg" class="download-button" title="download client for mac"><i class="fab fa-apple"></i></a>
<a href="https://github.com/NicolasConstant/sengi/releases/download/0.9.0/Sengi-0.9.0-linux.deb" class="download-button" title="download client for debian-based distrib"><i class="fab fa-ubuntu"></i></a>
<a href="https://snapcraft.io/sengi" title="use Snap Store for linux"><img src="images/snap-store-white.png" /></a>
</p>
</div>

View File

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

View File

@ -58,6 +58,8 @@ import { NotificationsComponent } from './components/floating-column/manage-acco
import { SettingsState } from './states/settings.state';
import { AccountEmojiPipe } from './pipes/account-emoji.pipe';
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 { ListAccountComponent } from './components/floating-column/manage-account/my-account/list-editor/list-account/list-account.component';
const routes: Routes = [
{ path: "", redirectTo: "home", pathMatch: "full" },
@ -104,7 +106,9 @@ const routes: Routes = [
MentionsComponent,
NotificationsComponent,
AccountEmojiPipe,
CardComponent
CardComponent,
ListEditorComponent,
ListAccountComponent
],
imports: [
FontAwesomeModule,

View File

@ -29,7 +29,6 @@ export class DirectMessagesComponent implements OnInit {
@Input('account')
set account(acc: AccountWrapper) {
console.warn('account');
this._account = acc;
this.getDirectMessages();
}

View File

@ -0,0 +1,21 @@
<div class="list-account">
<div class="list-account__action">
<a href class="list-account__action--button list-account__action--button--add" title="add account to list"
*ngIf="!accountWrapper.isInList" (click)="add()">
<fa-icon [icon]="faPlus"></fa-icon>
</a>
<a href class="list-account__action--button list-account__action--button--remove" title="remove account from list"
*ngIf="accountWrapper.isInList" (click)="remove()">
<fa-icon [icon]="faTimes"></fa-icon>
</a>
</div>
<div class="list-account__account">
<img src="{{ accountWrapper.account.avatar }}" alt="" class="list-account__account--avatar">
<span class="list-account__account--display-name" title="{{ accountWrapper.account.display_name }}" innerHTML="{{ accountWrapper.account | accountEmoji }}"></span>
<span class="list-account__account--acct" title="{{ accountWrapper.account.acct }}">{{ accountWrapper.account.acct }}</span>
</div>
</div>

View File

@ -0,0 +1,82 @@
@import "variables";
.list-account {
transition: all .2s;
$actin-width: 50px;
&__action {
float: right;
width: $actin-width;
position: relative;
&--button {
position: absolute;
top: 4px;
right: 12px;
color: #fff;
font-size: 14px;
padding: 10px;
// outline: 1px solid greenyellow;
&:hover{
color: darken(#fff, 20);
}
// $add-color: rgb(167, 220, 255);
// $remove-color: rgb(255, 154, 196);
// &--add {
// color: $add-color;
// &:hover{
// color: darken($add-color, 20);
// }
// }
&--remove {
color: $font-link-primary;
&:hover{
color: lighten($font-link-primary, 40);
}
}
}
}
&__account {
width: calc(100% - #{ $actin-width });
position: relative;
height: 50px;
&--avatar {
position: absolute;
top: 5px;
left: 5px;
width: 40px;
height: 40px;
}
$max-name-width: 190px;
&--display-name {
position: absolute;
top: 8px;
left: 60px;
color: white;
max-width: $max-name-width;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
&--acct {
position: absolute;
top: 26px;
left: 60px;
color: $status-secondary-color;
max-width: $max-name-width;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
}

View File

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

View File

@ -0,0 +1,37 @@
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { faTimes, faPlus } from "@fortawesome/free-solid-svg-icons";
import { Account } from "../../../../../../services/models/mastodon.interfaces";
import { AccountListWrapper } from '../list-editor.component';
import { isUndefined } from 'util';
@Component({
selector: 'app-list-account',
templateUrl: './list-account.component.html',
styleUrls: ['./list-account.component.scss']
})
export class ListAccountComponent implements OnInit {
faTimes = faTimes;
faPlus = faPlus;
@Input() accountWrapper: AccountListWrapper;
@Output() addEvent = new EventEmitter<AccountListWrapper>();
@Output() removeEvent = new EventEmitter<AccountListWrapper>();
constructor() { }
ngOnInit() {
}
add(): boolean {
if(this.accountWrapper && this.accountWrapper.isLoading) return;
this.addEvent.emit(this.accountWrapper);
return false;
}
remove(): boolean {
if(this.accountWrapper && this.accountWrapper.isLoading) return;
this.removeEvent.emit(this.accountWrapper);
return false;
}
}

View File

@ -0,0 +1,17 @@
<div class="list-editor">
<a href class="list-editor__close-search" title="close search" *ngIf="searchOpen" (click)="closeSearch()">
<fa-icon [icon]="faTimes"></fa-icon>
</a>
<input class="list-editor__search" placeholder="search account" [(ngModel)]="searchPattern"
(keyup.enter)="search()" />
<div class="list-editor__list flexcroll" *ngIf="!searchOpen">
<app-list-account class="list-editor__account" *ngFor="let account of accountsInList"
[accountWrapper]="account" (addEvent)="addEvent($event)" (removeEvent)="removeEvent($event)">
</app-list-account>
</div>
<div class="list-editor__list flexcroll" *ngIf="searchOpen">
<app-list-account class="list-editor__account" *ngFor="let account of accountsSearch"
[accountWrapper]="account" (addEvent)="addEvent($event)" (removeEvent)="removeEvent($event)">
</app-list-account>
</div>
</div>

View File

@ -0,0 +1,46 @@
@import "variables";
@import "commons";
.list-editor {
background-color: $color-primary;
min-height: 20px;
margin-top: 1px;
position: relative;
&__search {
color: #fff;
background-color: darken($color-primary, 4);
border: 2px solid $color-primary;
width: calc(100%);
padding: 3px 5px;
&:focus {
outline: none !important;
box-shadow: none;
}
}
&__close-search {
position: absolute;
top: 0px;
right: 5px;
color: white;
padding: 5px;
&:hover {
color: rgb(160, 160, 160);
}
}
&__list {
max-height: 300px;
overflow-y: auto;
border-top: 1px solid #000;
}
&__account {
display: block;
&:not(:last-child){
border-bottom: 1px solid #000;
}
}
}

View File

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

View File

@ -0,0 +1,148 @@
import { Component, OnInit, Input } from '@angular/core';
import { faTimes } from "@fortawesome/free-solid-svg-icons";
import { StreamWrapper } from '../my-account.component';
import { MastodonService } from '../../../../../services/mastodon.service';
import { AccountWrapper } from '../../../../../models/account.models';
import { NotificationService } from '../../../../../services/notification.service';
import { Account, Relationship, Instance } from "../../../../../services/models/mastodon.interfaces";
import { of } from 'rxjs';
@Component({
selector: 'app-list-editor',
templateUrl: './list-editor.component.html',
styleUrls: ['./list-editor.component.scss']
})
export class ListEditorComponent implements OnInit {
faTimes = faTimes;
@Input() list: StreamWrapper;
@Input() account: AccountWrapper;
accountsInList: AccountListWrapper[] = [];
accountsSearch: AccountListWrapper[] = [];
searchPattern: string;
searchOpen: boolean;
constructor(
private readonly notificationService: NotificationService,
private readonly mastodonService: MastodonService) { }
ngOnInit() {
this.accountsInList.length = 0;
this.mastodonService.getListAccounts(this.account.info, this.list.listId)
.then((accounts: Account[]) => {
this.accountsInList.length = 0;
for (const account of accounts) {
this.accountsInList.push(new AccountListWrapper(account, true));
}
})
.catch(err => {
this.notificationService.notifyHttpError(err);
});
}
search() {
if (this.searchPattern === '')
return this.closeSearch();
this.searchOpen = true;
this.accountsSearch.length = 0;
this.mastodonService.searchAccount(this.account.info, this.searchPattern, 15)
.then((accounts: Account[]) => {
this.accountsSearch.length = 0;
for (const account of accounts) {
const isInList = this.accountsInList.filter(x => x.account.id === account.id).length > 0;
this.accountsSearch.push(new AccountListWrapper(account, isInList));
}
})
.catch(err => {
this.notificationService.notifyHttpError(err);
});
}
closeSearch(): boolean {
this.searchPattern = null;
this.searchOpen = false;
this.accountsSearch.length = 0;
return false;
}
addEvent(accountWrapper: AccountListWrapper) {
console.log(accountWrapper);
accountWrapper.isLoading = true;
this.mastodonService.getInstance(this.account.info.instance)
.then((instance: Instance) => {
console.log(instance);
if (instance.version.toLowerCase().includes('pleroma')) {
return Promise.resolve(true);
} else {
return this.followAccount(accountWrapper);
}
})
.then(() => {
return this.mastodonService.addAccountToList(this.account.info, this.list.listId, accountWrapper.account.id);
})
.then(() => {
accountWrapper.isInList = true;
this.accountsInList.push(accountWrapper);
})
.catch(err => {
this.notificationService.notifyHttpError(err);
})
.then(() => {
accountWrapper.isLoading = false;
});
}
private followAccount(accountWrapper: AccountListWrapper): Promise<boolean> {
return this.mastodonService.getRelationships(this.account.info, [accountWrapper.account])
.then((relationships: Relationship[]) => {
var relationship = relationships.filter(x => x.id === accountWrapper.account.id)[0];
return relationship;
})
.then((relationship: Relationship) => {
if (relationship.following) {
return Promise.resolve(true);
} else {
return this.mastodonService.follow(this.account.info, accountWrapper.account)
.then((relationship: Relationship) => {
return new Promise<boolean>((resolve) => setTimeout(resolve, 1500));
// return Promise.resolve(relationship.following);
});
}
})
}
// private delay(t, v) {
// return new Promise(function(resolve) {
// setTimeout(resolve.bind(null, v), t)
// });
// }
removeEvent(accountWrapper: AccountListWrapper) {
console.log(accountWrapper);
accountWrapper.isLoading = true;
this.mastodonService.removeAccountFromList(this.account.info, this.list.listId, accountWrapper.account.id)
.then(() => {
accountWrapper.isInList = false;
this.accountsInList = this.accountsInList.filter(x => x.account.id !== accountWrapper.account.id);
})
.catch(err => {
this.notificationService.notifyHttpError(err);
})
.then(() => {
accountWrapper.isLoading = false;
});
}
}
export class AccountListWrapper {
constructor(public account: Account, public isInList: boolean) {
}
isProcessing: boolean;
isLoading: boolean;
}

View File

@ -1,9 +1,45 @@
<div class="my-account__body flexcroll">
<h4 class="my-account__label">add column:</h4>
<a class="my-account__link my-account__blue" href *ngFor="let stream of availableStreams"
(click)="addStream(stream)" title="{{ stream.isAdded ? '' : 'add timeline'}}" [class.my-account__link--disabled]="stream.isAdded">
{{ stream.name }} <fa-icon class="my-account__link--icon" *ngIf="stream.isAdded" [icon]="faCheckSquare"></fa-icon>
<h4 class="my-account__label">add timeline:</h4>
<a class="my-account__link my-account__link--margin-bottom my-account__blue" href
*ngFor="let stream of availableStreams" (click)="addStream(stream)"
title="{{ stream.isAdded ? '' : 'add timeline'}}" [class.my-account__link--disabled]="stream.isAdded">
{{ stream.name }} <fa-icon class="my-account__link--icon" *ngIf="stream.isAdded" [icon]="faCheckSquare">
</fa-icon>
</a>
<h4 class="my-account__label my-account__margin-top">manage list:</h4>
<div class="my-account__link--margin-bottom" *ngFor="let list of availableLists">
<a href class="my-account__list--button" title="delete list"
(click)="openCloseDeleteConfirmation(list, true)" *ngIf="!list.confirmDeletion">
<fa-icon class="my-account__link--icon" [icon]="faTrash"></fa-icon>
</a>
<a href class="my-account__list--button" title="edit list"
(click)="editList(list)" *ngIf="!list.confirmDeletion">
<fa-icon class="my-account__link--icon" [icon]="faPenAlt"></fa-icon>
</a>
<a href class="my-account__list--button" title="cancel"
(click)="openCloseDeleteConfirmation(list, false)" *ngIf="list.confirmDeletion">
<fa-icon class="my-account__link--icon" [icon]="faTimes"></fa-icon>
</a>
<a href class="my-account__list--button my-account__red" title="delete list"
(click)="deleteList(list)" *ngIf="list.confirmDeletion">
<fa-icon class="my-account__link--icon" [icon]="faCheck"></fa-icon>
</a>
<a class="my-account__link my-account__list my-account__blue" href (click)="addStream(list)"
title="{{ list.isAdded ? '' : 'add list'}}" [class.my-account__link--disabled]="list.isAdded">
{{ list.name }} <fa-icon class="my-account__link--icon" *ngIf="list.isAdded" [icon]="faCheckSquare">
</fa-icon>
</a>
<app-list-editor *ngIf="list.editList" [list]="list" [account]="account"></app-list-editor>
</div>
<a href class="my-account__list--button" title="create list" (click)="createList()">
<fa-icon class="my-account__link--icon" [icon]="faPlus"></fa-icon>
</a>
<input class="my-account__list--new-list-title" placeholder="new list title" [(ngModel)]="listTitle" (keyup.enter)="createList()" [disabled]="creationLoading"/>
<h4 class="my-account__label my-account__margin-top">remove account from sengi:</h4>
<a class="my-account__link my-account__red" href (click)="removeAccount()">
Delete

View File

@ -1,58 +1,105 @@
@import "variables";
@import "commons";
.my-account {
.my-account {
transition: all .2s;
&__body {
overflow: auto;
height: calc(100%);
padding-left: 10px;
padding-right: 10px;
padding-right: 10px;
font-size: $small-font-size;
padding-bottom: 20px;
outline: 1px dotted greenyellow;
}
&__label {
font-size: $small-font-size;
margin-top: 10px;
margin-left: 5px;
color: $font-color-secondary;
}
}
&__blue {
background-color: $color-primary;
color: #fff;
&:hover {
background-color: lighten($color-primary, 15);
}
}
&__red {
$red-button-color: rgb(65, 3, 3);
background-color: $red-button-color;
background-color: $red-button-color !important;
color: #fff;
&:hover {
background-color: lighten($red-button-color, 15);
background-color: lighten($red-button-color, 15) !important;
}
}
&__link {
text-decoration: none;
display: block; // width: calc(100% - 20px);
width: 100%; // height: 30px;
padding: 5px 10px; // border: solid 1px black;
&:not(:last-child) {
margin-bottom: 5px;
}
&--icon {
float: right;
}
&--disabled {
cursor: default;
background-color: darken($color-primary, 4);
&:hover {
background-color: darken($color-primary, 4);
}
}
&--margin-bottom {
&:not(:last-child) {
margin-bottom: 5px;
}
}
}
&__list {
$list-width: 60px;
width: calc(100% - #{$list-width} - 2px);
&--button {
margin-left: 1px;
width: calc(#{$list-width}/2);
float: right;
padding: 5px 10px;
background-color: $color-primary;
color: #fff;
color: $font-color-secondary;
&:hover {
color: #fff;
background-color: lighten($color-primary, 15);
}
}
&--new-list-title {
color: #fff;
background-color: darken($color-primary, 4);
border: 2px solid $color-primary;
width: calc(100% - #{$list-width}/2 - 1px);
padding: 3px 5px;
&:focus {
outline: none !important;
box-shadow: none;
}
}
}
&__margin-top {
margin-top: 25px;
}
}
}

View File

@ -2,12 +2,14 @@ import { Component, OnInit, OnDestroy, Input } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { Store, Select } from '@ngxs/store';
import { faCheckSquare } from "@fortawesome/free-regular-svg-icons";
import { faPenAlt, faTrash, faPlus, faCheck, faTimes } from "@fortawesome/free-solid-svg-icons";
import { NotificationService } from '../../../../services/notification.service';
import { StreamElement, StreamTypeEnum, AddStream, RemoveAllStreams } from '../../../../states/streams.state';
import { StreamElement, StreamTypeEnum, AddStream, RemoveAllStreams, RemoveStream } from '../../../../states/streams.state';
import { AccountWrapper } from '../../../../models/account.models';
import { RemoveAccount } from '../../../../states/accounts.state';
import { NavigationService } from '../../../../services/navigation.service';
import { MastodonService } from '../../../../services/mastodon.service';
@Component({
selector: 'app-my-account',
@ -15,10 +17,15 @@ import { NavigationService } from '../../../../services/navigation.service';
styleUrls: ['./my-account.component.scss']
})
export class MyAccountComponent implements OnInit, OnDestroy {
faPlus = faPlus;
faTrash = faTrash;
faPenAlt = faPenAlt;
faCheckSquare = faCheckSquare;
faCheck = faCheck;
faTimes = faTimes;
availableStreams: StreamWrapper[] = [];
availableLists: StreamWrapper[] = [];
private _account: AccountWrapper;
@Input('account')
@ -36,7 +43,8 @@ export class MyAccountComponent implements OnInit, OnDestroy {
constructor(
private readonly store: Store,
private readonly navigationService: NavigationService,
private notificationService: NotificationService) { }
private readonly mastodonService: MastodonService,
private readonly notificationService: NotificationService) { }
ngOnInit() {
this.streamChangedSub = this.streamElements$.subscribe((streams: StreamElement[]) => {
@ -53,9 +61,9 @@ export class MyAccountComponent implements OnInit, OnDestroy {
private loadStreams(account: AccountWrapper){
const instance = account.info.instance;
this.availableStreams.length = 0;
this.availableStreams.push(new StreamWrapper(new StreamElement(StreamTypeEnum.global, 'Federated Timeline', account.info.id, null, null, instance)));
this.availableStreams.push(new StreamWrapper(new StreamElement(StreamTypeEnum.local, 'Local Timeline', account.info.id, null, null, instance)));
this.availableStreams.push(new StreamWrapper(new StreamElement(StreamTypeEnum.personnal, 'Home', account.info.id, null, null, instance)));
this.availableStreams.push(new StreamWrapper(new StreamElement(StreamTypeEnum.global, 'Federated Timeline', account.info.id, null, null, null, instance)));
this.availableStreams.push(new StreamWrapper(new StreamElement(StreamTypeEnum.local, 'Local Timeline', account.info.id, null, null, null, instance)));
this.availableStreams.push(new StreamWrapper(new StreamElement(StreamTypeEnum.personnal, 'Home', account.info.id, null, null, null, instance)));
const loadedStreams = <StreamElement[]>this.store.snapshot().streamsstatemodel.streams;
this.availableStreams.forEach(s => {
@ -65,6 +73,24 @@ export class MyAccountComponent implements OnInit, OnDestroy {
s.isAdded = false;
}
});
this.availableLists.length = 0;
this.mastodonService.getLists(account.info)
.then((streams: StreamElement[]) => {
this.availableLists.length = 0;
for (let stream of streams) {
let wrappedStream = new StreamWrapper(stream);
if(loadedStreams.find(x => x.id == stream.id)){
wrappedStream.isAdded = true;
} else {
wrappedStream.isAdded = false;
}
this.availableLists.push(wrappedStream);
}
})
.catch(err => {
this.notificationService.notifyHttpError(err);
});
}
addStream(stream: StreamWrapper): boolean {
@ -72,7 +98,6 @@ export class MyAccountComponent implements OnInit, OnDestroy {
this.store.dispatch([new AddStream(stream)]).toPromise()
.then(() => {
stream.isAdded = true;
//this.notificationService.notify(`stream added`, false);
});
}
return false;
@ -84,12 +109,62 @@ export class MyAccountComponent implements OnInit, OnDestroy {
this.navigationService.closePanel();
return false;
}
listTitle: string;
creationLoading: boolean;
createList(): boolean {
if(this.creationLoading || !this.listTitle || this.listTitle == '') return false;
this.creationLoading = true;
this.mastodonService.createList(this.account.info, this.listTitle)
.then((stream: StreamElement) => {
this.listTitle = null;
let wrappedStream = new StreamWrapper(stream);
this.availableLists.push(wrappedStream);
})
.catch(err => {
this.notificationService.notifyHttpError(err);
})
.then(() => {
this.creationLoading = false;
});
return false;
}
editList(list: StreamWrapper): boolean {
list.editList = !list.editList;
return false;
}
openCloseDeleteConfirmation(list: StreamWrapper, state: boolean): boolean {
list.confirmDeletion = state;
return false;
}
deleteList(list: StreamWrapper): boolean {
this.mastodonService.deleteList(this.account.info, list.listId)
.then(() => {
const isAdded = this.availableLists.find(x => x.id === list.id).isAdded;
if(isAdded){
this.store.dispatch([new RemoveStream(list.id)]);
}
this.availableLists = this.availableLists.filter(x => x.id !== list.id);
})
.catch(err => {
this.notificationService.notifyHttpError(err);
});
return false;
}
}
class StreamWrapper extends StreamElement {
export class StreamWrapper extends StreamElement {
constructor(stream: StreamElement) {
super(stream.type, stream.name, stream.accountId, stream.tag, stream.list, stream.instance);
super(stream.type, stream.name, stream.accountId, stream.tag, stream.list, stream.listId, stream.instance);
}
isAdded: boolean;
confirmDeletion: boolean;
editList: boolean;
}

View File

@ -2,6 +2,7 @@
@import "mixins";
@import "panel";
@import "commons";
@import "buttons";
.panel {
padding-left: 0px;
padding-right: 0px;
@ -15,8 +16,35 @@
.form-with-button {
width: calc(100% - #{$button-size});
float: left;
background-color: $column-color;
border-color: $button-border-color;
color: #fff;
font-size: $default-font-size;
&:focus {
box-shadow: none;
}
height: 29px;
padding: 0 5px 0 5px;
}
// .form-control {
// margin: 0 0 5px 5px;
// width: calc(100% - 10px);
// background-color: $column-color;
// border-color: $status-secondary-color;
// color: #fff;
// font-size: $default-font-size;
// &:focus {
// box-shadow: none;
// }
// // &--privacy {
// // display: inline-block;
// // width: calc(100% - 15px - #{$btn-send-status-width} - #{$counter-width});
// // }
// }
.form-button {
width: $button-size;
height: 29px;
@ -25,11 +53,16 @@
cursor: pointer;
background-color: $button-background-color;
color: $button-color;
color: whitesmoke;
transition: all .2s;
&:hover {
background-color: $button-background-color-hover;
color: $button-color-hover;
}
border: 1px solid $button-border-color;
border-width: 1px 1px 1px 0;
}
$search-form-height: 70px;
@ -113,4 +146,4 @@ $search-form-height: 70px;
background-color: $button-background-color-hover;
}
@include clearfix;
}
}

View File

@ -1,6 +1,6 @@
<div class="panel">
<h3 class="panel__title">settings</h3>
<p class="version">Sengi version: 0.8.0</p>
<p class="version">Sengi version: {{version}}</p>
</div>

View File

@ -1,15 +1,18 @@
import { Component, OnInit } from '@angular/core';
import { environment } from '../../../../environments/environment';
@Component({
selector: 'app-settings',
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss']
selector: 'app-settings',
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss']
})
export class SettingsComponent implements OnInit {
constructor() { }
version: string;
ngOnInit() {
}
constructor() { }
}
ngOnInit() {
this.version = environment.VERSION;
}
}

View File

@ -11,6 +11,7 @@ $height-button: 40px;
.left-bar-link {
color: $font-link-primary;
color: darken(whitesmoke, 17);
text-decoration: none;
&:hover {
color: $font-link-primary-hover;
@ -28,23 +29,25 @@ $height-button: 40px;
&--status {
//margin-top: 3px;
font-size: 26px;
padding: 8px 0 2px 12px;
font-size: 22px;
padding: 8px 0 2px 13px;
}
&--search {
font-size: 28px;
padding: 0 0 0 11px;
font-size: 22px;
padding: 5px 0 0 13px;
}
&--add {
padding: 0 0 0 12px;
font-size: 26px;
padding: 5px 0 5px 13px;
font-size: 22px;
}
&--cog {
padding: 2px 0 0 9px;
position: absolute;
bottom: 7px;
opacity: .2;
opacity: .3;
transition: all .3s;
filter: alpha(opacity=20);
filter: alpha(opacity=30);
// color: darken($font-link-primary, 30);

View File

@ -50,7 +50,7 @@ export class HashtagComponent implements OnInit {
event.stopPropagation();
const hashtag = this.hashtagElement.tag;
const newStream = new StreamElement(StreamTypeEnum.tag, `${hashtag}`, this.lastUsedAccount.id, hashtag, null, this.lastUsedAccount.instance);
const newStream = new StreamElement(StreamTypeEnum.tag, `${hashtag}`, this.lastUsedAccount.id, hashtag, null, null, this.lastUsedAccount.instance);
this.store.dispatch([new AddStream(newStream)]);
return false;

View File

@ -17,7 +17,7 @@
display: block;
text-decoration: none;
color: whitesmoke;
&--image {
width: 55px;
height: 55px;
@ -52,14 +52,14 @@
}
&__photo {
}
&__photo {}
&__video {
// border-radius: 3px;
$height-content: 150px;
&--preview {
position: relative;
@ -83,7 +83,7 @@
font-size: 16px;
margin: 12px 5px 0 5px;
float: left;
&:hover {
opacity: 0.7;
}
@ -91,7 +91,7 @@
&--image {
width: 100%;
height: 150px;
height: $height-content;
// border: 1px solid salmon;
object-fit: cover;
}
@ -100,12 +100,10 @@
&--content {
//width: 300px;
width: 100%;
height: 150px;
height: $height-content;
background-color: #000;
}
}
&__rich {
}
&__rich {}
}

View File

@ -67,7 +67,7 @@
(accountSelected)="accountSelected($event)" (hashtagSelected)="hashtagSelected($event)"
(textSelected)="textSelected()"></app-databinded-text>
<app-card class="status__card" *ngIf="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"
[attachments]="displayedStatus.media_attachments">

View File

@ -156,7 +156,7 @@ export class StreamOverlayComponent implements OnInit, OnDestroy {
}
const selectedAccount = this.toolsService.getSelectedAccounts()[0];
const hashTagElement = new StreamElement(StreamTypeEnum.tag, hashtag, selectedAccount.id, hashtag, null, selectedAccount.instance);
const hashTagElement = new StreamElement(StreamTypeEnum.tag, hashtag, selectedAccount.id, hashtag, null, null, selectedAccount.instance);
const newElement = new OverlayBrowsing(hashTagElement, null, null);
this.loadElement(newElement);
// this.canGoForward = false;

View File

@ -187,7 +187,7 @@ export class StreamStatusesComponent implements OnInit, OnDestroy {
this.isProcessingInfiniteScroll = true;
const lastStatus = this.statuses[this.statuses.length - 1];
this.mastodonService.getTimeline(this.account, this._streamElement.type, lastStatus.status.id, null, this.streamingService.nbStatusPerIteration, this._streamElement.tag, this._streamElement.list)
this.mastodonService.getTimeline(this.account, this._streamElement.type, lastStatus.status.id, null, this.streamingService.nbStatusPerIteration, this._streamElement.tag, this._streamElement.listId)
.then((status: Status[]) => {
for (const s of status) {
const wrapper = new StatusWrapper(s, this.account);
@ -210,7 +210,7 @@ export class StreamStatusesComponent implements OnInit, OnDestroy {
private retrieveToots(): void {
this.mastodonService.getTimeline(this.account, this._streamElement.type, null, null, this.streamingService.nbStatusPerIteration, this._streamElement.tag, this._streamElement.list)
this.mastodonService.getTimeline(this.account, this._streamElement.type, null, null, this.streamingService.nbStatusPerIteration, this._streamElement.tag, this._streamElement.listId)
.then((results: Status[]) => {
this.isLoading = false;
for (const s of results) {

View File

@ -2,12 +2,12 @@ import { Injectable } from '@angular/core';
import { HttpHeaders, HttpClient, HttpResponse } from '@angular/common/http';
import { ApiRoutes } from './models/api.settings';
import { Account, Status, Results, Context, Relationship, Instance, Attachment, Notification } from "./models/mastodon.interfaces";
import { Account, Status, Results, Context, Relationship, Instance, Attachment, Notification, List } from "./models/mastodon.interfaces";
import { AccountInfo } from '../states/accounts.state';
import { StreamTypeEnum } from '../states/streams.state';
import { StreamTypeEnum, StreamElement } from '../states/streams.state';
@Injectable()
export class MastodonService {
export class MastodonService {
private apiRoutes = new ApiRoutes();
constructor(private readonly httpClient: HttpClient) { }
@ -22,13 +22,13 @@ export class MastodonService {
return this.httpClient.get<Account>('https://' + account.instance + this.apiRoutes.getCurrentAccount, { headers: headers }).toPromise();
}
getTimeline(account: AccountInfo, type: StreamTypeEnum, max_id: string = null, since_id: string = null, limit: number = 20, tag: string = null, list: string = null): Promise<Status[]> {
const route = `https://${account.instance}${this.getTimelineRoute(type, max_id, since_id, limit, tag, list)}`;
getTimeline(account: AccountInfo, type: StreamTypeEnum, max_id: string = null, since_id: string = null, limit: number = 20, tag: string = null, listId: string = null): Promise<Status[]> {
const route = `https://${account.instance}${this.getTimelineRoute(type, max_id, since_id, limit, tag, listId)}`;
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.get<Status[]>(route, { headers: headers }).toPromise();
}
private getTimelineRoute(type: StreamTypeEnum, max_id: string, since_id: string, limit: number, tag: string, list: string): string {
private getTimelineRoute(type: StreamTypeEnum, max_id: string, since_id: string, limit: number, tag: string, listId: string): string {
let route: string;
switch (type) {
case StreamTypeEnum.personnal:
@ -47,7 +47,7 @@ export class MastodonService {
route = this.apiRoutes.getTagTimeline.replace('{0}', tag);
break;
case StreamTypeEnum.list:
route = this.apiRoutes.getListTimeline.replace('{0}', list);
route = this.apiRoutes.getListTimeline.replace('{0}', listId);
break;
default:
throw new Error('StreamTypeEnum not supported');
@ -155,8 +155,8 @@ export class MastodonService {
});
}
searchAccount(account: AccountInfo, query: string, limit: number = 40, following: boolean = false): Promise<Account[]> {
const route = `https://${account.instance}${this.apiRoutes.searchForAccounts}?q=${query}&limit=${limit}&following=${following}`;
searchAccount(account: AccountInfo, query: string, limit: number = 40, following: boolean = false, resolve = true): Promise<Account[]> {
const route = `https://${account.instance}${this.apiRoutes.searchForAccounts}?q=${query}&limit=${limit}&following=${following}&resolve=${resolve}`;
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.get<Account[]>(route, { headers: headers }).toPromise()
}
@ -256,6 +256,55 @@ export class MastodonService {
result += `${paramName}[]=${x}`;
});
return result;
}
getLists(account: AccountInfo): Promise<StreamElement[]> {
let route = `https://${account.instance}${this.apiRoutes.getLists}`;
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.get<List[]>(route, { headers: headers }).toPromise()
.then((lists: List[]) => {
const streams: StreamElement[] = [];
for (const list of lists) {
const stream = new StreamElement(StreamTypeEnum.list, list.title, account.id, null, list.title, list.id, account.instance);
streams.push(stream);
}
return streams;
});
}
createList(account: AccountInfo, title: string): Promise<StreamElement> {
let route = `https://${account.instance}${this.apiRoutes.postList}?title=${title}`;
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.post<List>(route, null, { headers: headers }).toPromise()
.then((list: List) => {
return new StreamElement(StreamTypeEnum.list, list.title, account.id, null, list.title, list.id, account.instance);
});
}
deleteList(account: AccountInfo, listId: string): Promise<any> {
let route = `https://${account.instance}${this.apiRoutes.deleteList}`.replace('{0}', listId);
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.delete(route, { headers: headers }).toPromise();
}
getListAccounts(account: AccountInfo, listId: string): Promise<Account[]> {
let route = `https://${account.instance}${this.apiRoutes.getAccountsInList}`.replace('{0}', listId);
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.get<Account[]>(route, { headers: headers }).toPromise();
}
addAccountToList(account: AccountInfo, listId: string, accountId: number): Promise<any> {
let route = `https://${account.instance}${this.apiRoutes.addAccountToList}`.replace('{0}', listId);
route += `?account_ids[]=${accountId}`;
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.post(route, null, { headers: headers }).toPromise();
}
removeAccountFromList(account: AccountInfo, listId: string, accountId: number): Promise<any> {
let route = `https://${account.instance}${this.apiRoutes.addAccountToList}`.replace('{0}', listId);
route += `?account_ids[]=${accountId}`;
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.delete(route, { headers: headers }).toPromise();
}
}

View File

@ -50,4 +50,13 @@ export class ApiRoutes {
getTagTimeline = '/api/v1/timelines/tag/{0}';
getListTimeline = '/api/v1/timelines/list/{0}';
getStreaming = '/api/v1/streaming?access_token={0}&stream={1}';
getLists = '/api/v1/lists';
getList = '/api/v1/lists/{0}';
getListsWithAccount = '/api/v1/accounts/{0}/lists';
getAccountsInList = '/api/v1/lists/{0}/accounts';
postList = '/api/v1/lists';
putList = '/api/v1/lists/{0}';
deleteList = '/api/v1/lists/{0}';
addAccountToList = '/api/v1/lists/{0}/accounts';
removeAccountFromList = '/api/v1/lists/{0}/accounts';
}

View File

@ -188,3 +188,7 @@ export interface Tag {
url: string;
}
export interface List {
id: string;
title: string;
}

View File

@ -47,7 +47,7 @@ export class StreamingWrapper {
this.eventSource = new WebSocket(route);
this.eventSource.onmessage = x => this.statusParsing(<WebSocketEvent>JSON.parse(x.data));
this.eventSource.onerror = x => this.webSocketGotError(x);
this.eventSource.onopen = x => console.log(x);
this.eventSource.onopen = x => {};
this.eventSource.onclose = x => this.webSocketClosed(route, x);
}
@ -65,7 +65,7 @@ export class StreamingWrapper {
}
private pullNewStatuses(domain) {
this.mastodonService.getTimeline(this.account, this.stream.type, null, this.since_id, this.nbStatusPerIteration, this.stream.tag, this.stream.list)
this.mastodonService.getTimeline(this.account, this.stream.type, null, this.since_id, this.nbStatusPerIteration, this.stream.tag, this.stream.listId)
.then((status: Status[]) => {
// status = status.sort((n1, n2) => { return (<number>n1.id) < (<number>n2.id); });
status = status.sort((a, b) => a.id.localeCompare(b.id));
@ -112,7 +112,7 @@ export class StreamingWrapper {
let route = `wss://${account.instance}${this.apiRoutes.getStreaming}`.replace('{0}', account.token.access_token).replace('{1}', streamingRouteType);
if (stream.tag) route = `${route}&tag=${stream.tag}`;
if (stream.list) route = `${route}&tag=${stream.list}`;
if (stream.list) route = `${route}&list=${stream.listId}`;
return route;
}

View File

@ -98,6 +98,7 @@ export class StreamElement {
public accountId: string,
public tag: string,
public list: string,
public listId: string,
public instance: string) {
this.id = `${type}-${name}-${accountId}`;
}

View File

@ -1,3 +1,4 @@
export const environment = {
production: true
production: true,
VERSION: require('../../package.json').version
};

View File

@ -4,5 +4,6 @@
// The list of which env maps to which file can be found in `.angular-cli.json`.
export const environment = {
production: false
production: false,
VERSION: require('../../package.json').version
};

View File

@ -47,7 +47,7 @@ $button-color: darken(white, 30);
$button-color-hover: white;
$button-background-color: $color-primary;
$button-background-color-hover: lighten($color-primary, 20);
$button-border-color: #303957;
$column-background: #0f111a;

View File

@ -4,7 +4,7 @@
"outDir": "../out-tsc/app",
"baseUrl": "./",
"module": "es2015",
"types": []
"types": ["node"]
},
"exclude": [
"test.ts",