Merge pull request #25 from NicolasConstant/feature_handle-hashtags

Feature handle hashtags
This commit is contained in:
Nicolas Constant 2018-12-11 23:47:30 -05:00 committed by GitHub
commit 74a8dd21f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 717 additions and 406 deletions

View File

@ -41,6 +41,7 @@ import { HashtagComponent } from './components/stream/hashtag/hashtag.component'
import { StreamOverlayComponent } from './components/stream/stream-overlay/stream-overlay.component';
import { DatabindedTextComponent } from './components/stream/status/databinded-text/databinded-text.component';
import { TimeAgoPipe } from './pipes/time-ago.pipe';
import { StreamStatusesComponent } from './components/stream/stream-statuses/stream-statuses.component';
const routes: Routes = [
{ path: "", redirectTo: "home", pathMatch: "full" },
@ -74,7 +75,8 @@ const routes: Routes = [
HashtagComponent,
StreamOverlayComponent,
DatabindedTextComponent,
TimeAgoPipe
TimeAgoPipe,
StreamStatusesComponent
],
imports: [
BrowserModule,

View File

@ -1,11 +1,13 @@
<div class="floating-column">
<div class="floating-column__header">
<a class="close-button" href (click)="closePanel()" title="close">x</a>
</div>
<app-stream-overlay class="stream-overlay" *ngIf="overlayActive" (closeOverlay)="closeOverlay()" [browseAccount]="overlayAccountToBrowse" [browseHashtag]="overlayHashtagToBrowse"></app-stream-overlay>
<app-manage-account *ngIf="openPanel === 'manageAccount'" [account]="userAccountUsed"></app-manage-account>
<app-add-new-status *ngIf="openPanel === 'createNewStatus'"></app-add-new-status>
<app-add-new-account *ngIf="openPanel === 'addNewAccount'"></app-add-new-account>
<app-search *ngIf="openPanel === 'search'"></app-search>
<app-settings *ngIf="openPanel === 'settings'"></app-settings>
<div class="floating-column__header">
<a class="close-button" href (click)="closePanel()" title="close">x</a>
</div>
<app-manage-account *ngIf="openPanel === 'manageAccount'" [account]="userAccountUsed"></app-manage-account>
<app-add-new-status *ngIf="openPanel === 'createNewStatus'"></app-add-new-status>
<app-add-new-account *ngIf="openPanel === 'addNewAccount'"></app-add-new-account>
<app-search *ngIf="openPanel === 'search'" (browseAccount)="browseAccount($event)" (browseHashtag)="browseHashtag($event)"></app-search>
<app-settings *ngIf="openPanel === 'settings'"></app-settings>
</div>

View File

@ -1,9 +1,11 @@
@import "variables";
@import "mixins";
$floating-column-size: 330px;
.floating-column {
width: calc(100%);
max-width: 330px;
width: $floating-column-size;
background-color: $color-secondary;
overflow: hidden;
@ -13,12 +15,17 @@
bottom: $stream-selector-height;
padding: 0;
&__header {
// &__header {
// @include clearfix;
}
// }
}
.stream-overlay {
position: absolute;
z-index: 50;
width: $floating-column-size;
height: calc(100%);
}
.close-button {
// display: inline-block;

View File

@ -8,11 +8,14 @@ import { AccountWrapper } from '../../models/account.models';
styleUrls: ['./floating-column.component.scss']
})
export class FloatingColumnComponent implements OnInit {
overlayActive: boolean;
overlayAccountToBrowse: string;
overlayHashtagToBrowse: string;
userAccountUsed: AccountWrapper;
openPanel: string;
constructor(private readonly navigationService: NavigationService) { }
ngOnInit() {
@ -48,4 +51,26 @@ export class FloatingColumnComponent implements OnInit {
return false;
}
browseAccount(account: string): void {
this.overlayAccountToBrowse = account;
this.overlayHashtagToBrowse = null;
this.overlayActive = true;
}
browseHashtag(hashtag: string): void {
this.overlayAccountToBrowse = null;
this.overlayHashtagToBrowse = hashtag;
this.overlayActive = true;
}
browseThread(thread: string): void {
console.warn('browseThread');
console.warn(thread);
}
closeOverlay(): boolean {
this.overlayActive = false;
return false;
}
}

View File

@ -18,9 +18,9 @@ export class ManageAccountComponent implements OnInit {
ngOnInit() {
this.availableStreams.length = 0;
this.availableStreams.push(new StreamElement(StreamTypeEnum.global, 'Global Timeline', this.account.info.id));
this.availableStreams.push(new StreamElement(StreamTypeEnum.local, 'Local Timeline', this.account.info.id));
this.availableStreams.push(new StreamElement(StreamTypeEnum.personnal, 'Personnal Timeline', this.account.info.id));
this.availableStreams.push(new StreamElement(StreamTypeEnum.global, 'Global Timeline', this.account.info.id, null, null));
this.availableStreams.push(new StreamElement(StreamTypeEnum.local, 'Local Timeline', this.account.info.id, null, null));
this.availableStreams.push(new StreamElement(StreamTypeEnum.personnal, 'Personnal Timeline', this.account.info.id, null, null));
}
addStream(stream: StreamElement): boolean {

View File

@ -1,31 +1,36 @@
<div class="panel">
<h3 class="panel__title">search</h3>
<form (ngSubmit)="onSubmit()">
<input type="text" class="form-control form-control-sm" [(ngModel)]="searchHandle" name="searchHandle"
<form class="form-section" (ngSubmit)="onSubmit()">
<input type="text" class="form-control form-control-sm form-with-button" [(ngModel)]="searchHandle" name="searchHandle"
placeholder="Search" autocomplete="off" />
<button class="form-button" type="submit" title="search">GO</button>
</form>
<app-waiting-animation *ngIf="isLoading" class="waiting-icon"></app-waiting-animation>
<div *ngIf="accounts.length > 0" class="search-results">
<h3 class="search-results__title">Accounts</h3>
<a href *ngFor="let account of accounts" class="account">
<a href *ngFor="let account of accounts" class="account" title="open account"
(click)="selectAccount(account.acct)">
<img src="{{account.avatar}}" class="account__avatar" />
<div class="account__name">{{ account.username }}</div>
<div class="account__fullhandle">@{{ account.acct }}</div>
</a>
</div>
<div *ngIf="statuses.length > 0" class="search-results">
<h3 class="search-results__title">Statuses</h3>
</div>
<div *ngIf="hashtags.length > 0" class="search-results">
<h3 class="search-results__title">Hashtags</h3>
<a href *ngFor="let hashtag of hashtags" class="search-results__hashtag">
<a (click)="selectHashtag(hashtag)" href *ngFor="let hashtag of hashtags" class="search-results__hashtag" title="browse hashtag">
#{{ hashtag }}
</a>
</div>
<div *ngIf="statuses.length > 0" class="search-results">
<h3 class="search-results__title">Statuses</h3>
<div class="search-results__status" *ngFor="let statusWrapper of statuses">
<app-status [statusWrapper]="statusWrapper" (browseAccount)="browseAccount($event)" (browseHashtag)="browseHashtag($event)"></app-status>
</div>
</div>
</div>

View File

@ -3,66 +3,105 @@
@import "panel";
@import "commons";
$separator-color:$color-primary;
.form-section {
overflow: auto;
width: 100%;
}
.form-with-button {
width: calc(100% - #{$button-size});
float: left;
}
.form-button {
width: $button-size;
height: 29px;
border: none;
outline: none;
cursor: pointer;
background-color: $button-background-color;
color: $button-color;
transition: all .2s;
&:hover {
background-color: $button-background-color-hover;
color: $button-color-hover;
}
}
.search-results {
// outline: 1px solid greenyellow;
margin-top: 10px;
// &:first-of-type{
margin-top: 10px; // &:first-of-type{
// margin-top: 10px;
// }
&__title {
text-transform: uppercase;
font-size: 13px;
}
&__hashtag {
border-radius: 2px;
display: block;
padding: 5px;
color: white;
border-top: 1px solid $separator-color;
&:last-of-type{
text-decoration: none;
transition: all .3s;
&:hover {
background-color: $button-background-color-hover;
}
border-top: 1px solid $separator-color;
&:last-of-type {
border-bottom: 1px solid $separator-color;
}
}
}
&__status {
border-top: 1px solid $separator-color;
&:last-of-type {
border-bottom: 1px solid $separator-color;
}
}
}
.account {
display: block;
color: white;
display: block;
color: white;
&:hover {
text-decoration: none;
}
border-radius: 2px;
transition: all .3s;
&:hover &__name {
text-decoration: underline;
}
border-top: 1px solid $separator-color;
&:last-of-type{
// &:hover &__name {
// text-decoration: underline;
// }
border-top: 1px solid $separator-color;
&:last-of-type {
border-bottom: 1px solid $separator-color;
}
}
&__avatar {
width: 40px;
margin: 5px 10px 5px 5px;
float: left;
border-radius: 2px;
}
&__name {
margin: 7px 0 0 0;
margin: 7px 0 0 0;
}
&__fullhandle {
margin: 0 0 5px 0;
color: $status-secondary-color;
transition: all .3s;
// &:hover {
// color: white;
// }
}
&:hover,
&:hover &__fullhandle {
color: white;
text-decoration: none;
background-color: $button-background-color-hover;
}
@include clearfix;

View File

@ -1,9 +1,12 @@
import { Component, OnInit, Input } from '@angular/core';
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { Store } from '@ngxs/store';
import { MastodonService } from '../../../services/mastodon.service';
import { AccountInfo } from '../../../states/accounts.state';
import { Results, Account, Status } from '../../../services/models/mastodon.interfaces';
import { ToolsService } from '../../../services/tools.service';
import { StatusWrapper } from '../../stream/stream.component';
import { StreamElement, StreamTypeEnum, AddStream } from './../../../states/streams.state';
@Component({
@ -15,13 +18,18 @@ export class SearchComponent implements OnInit {
@Input() searchHandle: string;
accounts: Account[] = [];
statuses: Status[] = [];
statuses: StatusWrapper[] = [];
hashtags: string[] = [];
isLoading: boolean;
@Output() browseAccount = new EventEmitter<string>();
@Output() browseHashtag = new EventEmitter<string>();
@Output() browseThread = new EventEmitter<string>();
constructor(
private readonly store: Store,
private readonly toolsService: ToolsService,
private readonly mastodonService: MastodonService) { }
ngOnInit() {
@ -33,6 +41,31 @@ export class SearchComponent implements OnInit {
return false;
}
selectHashtag(hashtag: string): boolean {
if (hashtag) {
this.browseHashtag.next(hashtag);
}
return false;
}
// addHashtag(hashtag: string): boolean {
// if (hashtag) {
// const newStream = new StreamElement(StreamTypeEnum.tag, `#${hashtag}`, this.lastAccountUsed.id, hashtag, null);
// this.store.dispatch([new AddStream(newStream)]);
// }
// return false;
// }
selectAccount(accountName: string): boolean {
console.warn(accountName);
if (accountName) {
this.browseAccount.next(accountName);
}
return false;
}
private lastAccountUsed: AccountInfo;
private search(data: string) {
this.accounts.length = 0;
this.statuses.length = 0;
@ -41,23 +74,23 @@ export class SearchComponent implements OnInit {
console.warn(`search: ${data}`);
const enabledAccounts = this.getRegisteredAccounts().filter(x => x.isSelected);
const enabledAccounts = this.toolsService.getSelectedAccounts();
//First candid implementation
if (enabledAccounts.length > 0) {
const candid_oneAccount = enabledAccounts[0];
this.mastodonService.search(candid_oneAccount, data, true)
this.lastAccountUsed = enabledAccounts[0];
this.mastodonService.search(this.lastAccountUsed, data, true)
.then((results: Results) => {
if (results) {
console.warn(results);
this.accounts = results.accounts;
//this.statuses = results.statuses;
this.accounts = results.accounts.slice(0, 5);
this.hashtags = results.hashtags;
//TODO: Pleroma return more than mastodon, will have to handle that
if (this.accounts.length > 5) {
this.accounts.length = 5;
for (let status of results.statuses) {
const statusWrapper = new StatusWrapper(status, this.lastAccountUsed);
this.statuses.push(statusWrapper);
}
}
})
.catch((err) => console.error(err))

View File

@ -1,3 +1,11 @@
<p>
hashtag works!
</p>
<div class="hashtag-column">
<div class="hashtag-header">
<a href (click)="goToTop()" class="hashtag-header__gototop" title="go to top">
<h3 class="hashtag-header__title">#{{hashtagElement.tag}}</h3>
<button class="btn-custom-secondary hashtag-header__add-column" (click)="addColumn($event)" title="add column to board">add column</button>
</a>
</div>
<app-stream-statuses class="hashtag-stream" *ngIf="hashtagElement" [streamElement]="hashtagElement" [goToTop]="goToTopSubject.asObservable()"
(browseAccount)="selectAccount($event)" (browseHashtag)="selectHashtag($event)"></app-stream-statuses>
</div>

View File

@ -0,0 +1,50 @@
@import "variables";
@import "buttons";
$hashtag-header-height: 40px;
$inner-column-size: 320px;
.hashtag-column{
height: calc(100%);
width: $inner-column-size;
}
.hashtag-header {
height: $hashtag-header-height;
border-bottom: 1px solid black;
position: relative;
&__gototop {
display: block;
width: calc(100%);
height: calc(100%);
color: white;
transition: all .2s;
// &:hover {
// background-color: $btn-primary-color-hover;
// }
}
&__title {
font-size: 1em;
position: absolute;
top: 10px;
left: 10px;
}
&__add-column {
position: absolute;
top: 7px;
right: 7px;
padding: 0 10px 0 10px;
border: 1px solid black;
color: white;
}
}
.hashtag-stream {
display: block;
height: calc(100% - #{$hashtag-header-height} - 30px);
width: $inner-column-size;
// outline: 1px greenyellow solid;
}

View File

@ -1,4 +1,8 @@
import { Component, OnInit, Output, EventEmitter, Input } from '@angular/core';
import { Subject } from 'rxjs';
import { Store } from '@ngxs/store';
import { StreamElement, StreamTypeEnum, AddStream } from '../../../states/streams.state';
@Component({
selector: 'app-hashtag',
@ -6,19 +10,39 @@ import { Component, OnInit, Output, EventEmitter, Input } from '@angular/core';
styleUrls: ['./hashtag.component.scss']
})
export class HashtagComponent implements OnInit {
hashtag: string;
@Output() browseAccount = new EventEmitter<string>();
@Output() browseHashtag = new EventEmitter<string>();
@Input('currentHashtag')
set currentAccount(hashtag: string) {
this.hashtag = hashtag;
}
@Input() hashtagElement: StreamElement;
constructor() { }
goToTopSubject: Subject<void> = new Subject<void>();
constructor(
private readonly store: Store) { }
ngOnInit() {
}
goToTop(): boolean {
this.goToTopSubject.next();
return false;
}
addColumn(event): boolean {
event.stopPropagation();
const hashtag = this.hashtagElement.tag;
const newStream = new StreamElement(StreamTypeEnum.tag, `#${hashtag}`, this.hashtagElement.accountId, hashtag, null);
this.store.dispatch([new AddStream(newStream)]);
return false;
}
selectAccount(account: string) {
this.browseAccount.next(account);
}
selectHashtag(hashtag: string) {
this.browseHashtag.next(hashtag);
}
}

View File

@ -10,6 +10,6 @@
</div> -->
<app-user-profile *ngIf="accountName" [currentAccount]="accountName" (browseAccount)="accountSelected($event)" (browseHashtag)="hashtagSelected($event)"></app-user-profile>
<app-hashtag *ngIf="browseHashtag"></app-hashtag>
<app-hashtag *ngIf="hashtagElement" [hashtagElement]="hashtagElement" (browseAccount)="accountSelected($event)" (browseHashtag)="hashtagSelected($event)"></app-hashtag>
<app-thread *ngIf="browseThread"></app-thread>
</div>

View File

@ -1,13 +1,9 @@
@import "variables";
@import "commons";
.stream-overlay {
// position: absolute;
// z-index: 50;
width: $stream-column-width;
// width: $stream-column-width;
height: calc(100%);
background-color: $column-color; // margin: 0 0 0 $stream-column-separator;
// outline: 1px red solid;
// float: left;
background-color: $column-color;
&__header {
width: calc(100%);
height: 30px;
@ -16,7 +12,7 @@
& a {
color: whitesmoke;
font-size: 0.8em;
font-weight: normal; // margin: 0;
font-weight: normal;
}
}
&__title {

View File

@ -2,6 +2,7 @@ import { Component, OnInit, Output, EventEmitter, Input } from '@angular/core';
import { Account, Results } from "../../../services/models/mastodon.interfaces";
import { MastodonService } from '../../../services/mastodon.service';
import { ToolsService } from '../../../services/tools.service';
import { StreamElement, StreamTypeEnum } from '../../../states/streams.state';
@Component({
selector: 'app-stream-overlay',
@ -18,7 +19,8 @@ export class StreamOverlayComponent implements OnInit {
accountName: string;
thread: string;
hashtag: string;
// hashtag: string;
hashtagElement: StreamElement;
@Output() closeOverlay = new EventEmitter();
@ -39,7 +41,7 @@ export class StreamOverlayComponent implements OnInit {
// this.hashtag = hashtag;
}
constructor() { }
constructor(private toolsService: ToolsService) { }
ngOnInit() {
}
@ -63,6 +65,7 @@ export class StreamOverlayComponent implements OnInit {
const nextElement = this.nextElements.pop();
this.loadElement(nextElement);
if(this.nextElements.length === 0) this.canGoForward = false;
return false;
}
@ -111,7 +114,10 @@ export class StreamOverlayComponent implements OnInit {
if (this.currentElement) {
this.previousElements.push(this.currentElement);
}
const newElement = new OverlayBrowsing(hashtag, null, null);
const selectedAccount = this.toolsService.getSelectedAccounts()[0];
const hashTagElement = new StreamElement(StreamTypeEnum.tag, hashtag, selectedAccount.id, hashtag, null);
const newElement = new OverlayBrowsing(hashTagElement, null, null);
this.loadElement(newElement);
this.canGoForward = false;
}
@ -120,14 +126,14 @@ export class StreamOverlayComponent implements OnInit {
this.currentElement = element;
this.accountName = this.currentElement.account;
this.hashtag = this.currentElement.hashtag;
this.thread = this.currentElement.thread;
this.hashtagElement = this.currentElement.hashtag;
}
}
class OverlayBrowsing {
constructor(
public readonly hashtag: string,
public readonly hashtag: StreamElement,
public readonly account: string,
public readonly thread: string) {

View File

@ -0,0 +1,6 @@
<div class="stream-toots flexcroll" #statusstream (scroll)="onScroll()">
<!-- data-simplebar -->
<div class="stream-toots__status" *ngFor="let statusWrapper of statuses">
<app-status [statusWrapper]="statusWrapper" (browseAccount)="accountSelected($event)" (browseHashtag)="hashtagSelected($event)"></app-status>
</div>
</div>

View File

@ -0,0 +1,13 @@
@import "variables";
@import "commons";
.stream-toots {
height: calc(100%);
width: calc(100%);
overflow: auto;
&__status:not(:last-child) {
border: solid #06070b;
border-width: 0 0 1px 0;
}
}

View File

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

View File

@ -0,0 +1,208 @@
import { Component, OnInit, Input, ViewChild, ElementRef, OnDestroy, EventEmitter, Output } from '@angular/core';
import { Store } from '@ngxs/store';
import { StreamElement } from '../../../states/streams.state';
import { AccountInfo } from '../../../states/accounts.state';
import { StreamingService, EventEnum, StreamingWrapper, StatusUpdate } from '../../../services/streaming.service';
import { Status } from '../../../services/models/mastodon.interfaces';
import { MastodonService } from '../../../services/mastodon.service';
import { Observable, Subscription } from 'rxjs';
import { StatusWrapper } from '../stream.component';
@Component({
selector: 'app-stream-statuses',
templateUrl: './stream-statuses.component.html',
styleUrls: ['./stream-statuses.component.scss']
})
export class StreamStatusesComponent implements OnInit, OnDestroy {
private _streamElement: StreamElement;
private account: AccountInfo;
private websocketStreaming: StreamingWrapper;
statuses: StatusWrapper[] = [];
private bufferStream: Status[] = [];
private bufferWasCleared: boolean;
@Output() browseAccount = new EventEmitter<string>();
@Output() browseHashtag = new EventEmitter<string>();
@Output() browseThread = new EventEmitter<string>();
@Input()
set streamElement(streamElement: StreamElement) {
console.warn('new stream');
this.resetStream();
this._streamElement = streamElement;
const splitedUserName = streamElement.accountId.split('@');
const user = splitedUserName[0];
const instance = splitedUserName[1];
this.account = this.getRegisteredAccounts().find(x => x.username == user && x.instance == instance);
this.retrieveToots();
this.launchWebsocket();
}
get streamElement(): StreamElement {
return this._streamElement;
}
@Input() goToTop: Observable<void>;
private goToTopSubscription: Subscription;
constructor(
private readonly store: Store,
private readonly streamingService: StreamingService,
private readonly mastodonService: MastodonService) {
}
ngOnInit() {
this.goToTopSubscription = this.goToTop.subscribe(() => {
this.applyGoToTop();
});
}
ngOnDestroy(){
if( this.goToTopSubscription) this.goToTopSubscription.unsubscribe();
}
private resetStream() {
this.statuses.length = 0;
this.bufferStream.length = 0;
if(this.websocketStreaming) this.websocketStreaming.dispose();
}
private launchWebsocket(): void {
this.websocketStreaming = this.streamingService.getStreaming(this.account, this._streamElement);
this.websocketStreaming.statusUpdateSubjet.subscribe((update: StatusUpdate) => {
if (update) {
if (update.type === EventEnum.update) {
if (!this.statuses.find(x => x.status.id == update.status.id)) {
if (this.streamPositionnedAtTop) {
const wrapper = new StatusWrapper(update.status, this.account);
this.statuses.unshift(wrapper);
} else {
this.bufferStream.push(update.status);
}
}
}
}
this.checkAndCleanUpStream();
});
}
@ViewChild('statusstream') public statustream: ElementRef;
private applyGoToTop(): boolean {
this.loadBuffer();
if (this.statuses.length > 2 * this.streamingService.nbStatusPerIteration) {
this.statuses.length = 2 * this.streamingService.nbStatusPerIteration;
}
const stream = this.statustream.nativeElement as HTMLElement;
stream.scrollTo({
top: 0,
behavior: 'smooth'
});
return false;
}
private streamPositionnedAtTop: boolean = true;
private isProcessingInfiniteScroll: boolean;
onScroll() {
var element = this.statustream.nativeElement as HTMLElement;
const atBottom = element.scrollHeight <= element.clientHeight + element.scrollTop + 1000;
const atTop = element.scrollTop === 0;
this.streamPositionnedAtTop = false;
if (atBottom && !this.isProcessingInfiniteScroll) {
this.scrolledToBottom();
} else if (atTop) {
this.scrolledToTop();
}
}
accountSelected(accountName: string): void {
console.warn(`status comp: accountSelected ${accountName}`);
this.browseAccount.next(accountName);
}
hashtagSelected(hashtag: string): void {
console.warn(`status comp: hashtagSelected ${hashtag}`);
this.browseHashtag.next(hashtag);
}
textSelected(): void {
console.warn(`status comp: textSelected`);
}
private scrolledToTop() {
this.streamPositionnedAtTop = true;
this.loadBuffer();
}
private loadBuffer(){
if(this.bufferWasCleared) {
this.statuses.length = 0;
this.bufferWasCleared = false;
}
for (const status of this.bufferStream) {
const wrapper = new StatusWrapper(status, this.account);
this.statuses.unshift(wrapper);
}
this.bufferStream.length = 0;
}
private scrolledToBottom() {
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)
.then((status: Status[]) => {
for (const s of status) {
const wrapper = new StatusWrapper(s, this.account);
this.statuses.push(wrapper);
}
})
.catch(err => {
console.error(err);
})
.then(() => {
this.isProcessingInfiniteScroll = false;
});
}
private getRegisteredAccounts(): AccountInfo[] {
var regAccounts = <AccountInfo[]>this.store.snapshot().registeredaccounts.accounts;
return regAccounts;
}
private retrieveToots(): void {
this.mastodonService.getTimeline(this.account, this._streamElement.type, null, null, this.streamingService.nbStatusPerIteration, this._streamElement.tag, this._streamElement.list)
.then((results: Status[]) => {
for (const s of results) {
const wrapper = new StatusWrapper(s, this.account);
this.statuses.push(wrapper);
}
});
}
private checkAndCleanUpStream(): void {
if (this.streamPositionnedAtTop && this.statuses.length > 3 * this.streamingService.nbStatusPerIteration) {
this.statuses.length = 2 * this.streamingService.nbStatusPerIteration;
}
if (this.bufferStream.length > 3 * this.streamingService.nbStatusPerIteration) {
this.bufferWasCleared = true;
this.bufferStream.length = 2 * this.streamingService.nbStatusPerIteration;
}
}
}

View File

@ -10,10 +10,10 @@
<h1>{{ streamElement.name.toUpperCase() }}</h1>
</a>
</div>
<div class="stream-toots flexcroll" #statusstream (scroll)="onScroll()">
<!-- data-simplebar -->
<app-stream-statuses class="stream-statuses" [streamElement]="streamElement" [goToTop]="goToTopSubject.asObservable()" (browseAccount)="browseAccount($event)" (browseHashtag)="browseHashtag($event)"></app-stream-statuses>
<!-- <div class="stream-toots flexcroll" #statusstream (scroll)="onScroll()">
<div class="stream-toots__status" *ngFor="let statusWrapper of statuses">
<app-status [statusWrapper]="statusWrapper" (browseAccount)="browseAccount($event)" (browseHashtag)="browseHashtag($event)"></app-status>
</div>
</div>
</div> -->
</div>

View File

@ -22,33 +22,19 @@
}
}
.stream-toots {
.stream-statuses {
display: block;
height: calc(100% - 30px);
width: 320px;
overflow: auto;
&__status:not(:last-child) {
border: solid #06070b;
border-width: 0 0 1px 0;
}
}
// .flexcroll {
// scrollbar-face-color: #08090d;
// scrollbar-shadow-color: #08090d;
// scrollbar-highlight-color: #08090d;
// scrollbar-3dlight-color: #08090d;
// scrollbar-darkshadow-color: #08090d;
// scrollbar-track-color: #08090d;
// scrollbar-arrow-color: #08090d;
// &::-webkit-scrollbar {
// width: 7px;
// }
// &::-webkit-scrollbar-thumb {
// -webkit-border-radius: 0px;
// border-radius: 0px;
// // background: #08090d;
// background: lighten($color-primary, 5);
// // -webkit-box-shadow: inset 0 0 3px rgba(0, 0, 0, 0.5);
// .stream-toots {
// height: calc(100% - 30px);
// width: 320px;
// overflow: auto;
// &__status:not(:last-child) {
// border: solid #06070b;
// border-width: 0 0 1px 0;
// }
// }
@ -58,48 +44,4 @@
z-index: 50;
width: $stream-column-width;
height: calc(100%);
// background-color: rgba(#ff0000, 0.3);
// // margin: 0 0 0 $stream-column-separator;
// // outline: 1px red solid;
// // float: left;
// &__header {
// width: calc(100%);
// height: 30px;
// background-color: $column-header-background-color;
// padding: 6px 10px 0 10px;
// & a {
// color: whitesmoke;
// font-size: 0.8em;
// font-weight: normal;
// margin: 0;
// }
// }
// &__title {
// width: calc(100%);
// height: 30px;
// background-color: $column-header-background-color;
// border-top: 1px solid whitesmoke;
// border-bottom: 1px solid whitesmoke;
// padding: 3px 10px 0 10px;
// }
}
// .overlay-previous {
// display: block;
// float: left;
// }
// .overlay-next {
// display: block;
// float: right;
// padding-right: 20px;
// }
// .overlay-close {
// display: block;
// float: right;
// }
}

View File

@ -1,11 +1,9 @@
import { Component, OnInit, Input, ElementRef, ViewChild, HostListener } from "@angular/core";
import { AccountWrapper } from "../../models/account.models";
import { StreamElement, StreamTypeEnum } from "../../states/streams.state";
import { StreamingService, StreamingWrapper, EventEnum, StatusUpdate } from "../../services/streaming.service";
import { Store } from "@ngxs/store";
import { AccountInfo } from "../../states/accounts.state";
import { Subject } from "rxjs";
import { StreamElement } from "../../states/streams.state";
import { Status } from "../../services/models/mastodon.interfaces";
import { MastodonService } from "../../services/mastodon.service";
import { AccountInfo } from "../../states/accounts.state";
@Component({
selector: "app-stream",
@ -13,55 +11,34 @@ import { MastodonService } from "../../services/mastodon.service";
styleUrls: ["./stream.component.scss"]
})
export class StreamComponent implements OnInit {
private _streamElement: StreamElement;
private account: AccountInfo;
private websocketStreaming: StreamingWrapper;
statuses: StatusWrapper[] = [];
private bufferStream: Status[] = [];
private bufferWasCleared: boolean;
overlayActive: boolean;
overlayAccountToBrowse: string;
overlayHashtagToBrowse: string;
@Input()
set streamElement(streamElement: StreamElement) {
this._streamElement = streamElement;
goToTopSubject: Subject<void> = new Subject<void>();
const splitedUserName = streamElement.accountId.split('@');
const user = splitedUserName[0];
const instance = splitedUserName[1];
this.account = this.getRegisteredAccounts().find(x => x.username == user && x.instance == instance);
@Input() streamElement: StreamElement;
this.retrieveToots();
this.launchWebsocket();
}
get streamElement(): StreamElement {
return this._streamElement;
}
constructor(
private readonly store: Store,
private readonly streamingService: StreamingService,
private readonly mastodonService: MastodonService) {
}
constructor() { }
ngOnInit() {
}
goToTop(): boolean {
this.goToTopSubject.next();
return false;
}
browseAccount(account: string): void {
this.overlayAccountToBrowse = account;
this.overlayHashtagToBrowse = null;
this.overlayActive = true;
this.overlayActive = true;
}
browseHashtag(hashtag: string): void {
console.warn(`browseHashtag ${hashtag}`);
this.overlayAccountToBrowse = null;
this.overlayHashtagToBrowse = hashtag;
this.overlayActive = true;
this.overlayActive = true;
}
browseThread(thread: string): void {
@ -73,127 +50,11 @@ export class StreamComponent implements OnInit {
this.overlayAccountToBrowse = null;
this.overlayActive = false;
}
@ViewChild('statusstream') public statustream: ElementRef;
goToTop(): boolean {
this.loadBuffer();
if (this.statuses.length > 40) {
this.statuses.length = 40;
}
const stream = this.statustream.nativeElement as HTMLElement;
stream.scrollTo({
top: 0,
behavior: 'smooth'
});
return false;
}
private streamPositionnedAtTop: boolean = true;
private isProcessingInfiniteScroll: boolean;
onScroll() {
var element = this.statustream.nativeElement as HTMLElement;
const atBottom = element.scrollHeight <= element.clientHeight + element.scrollTop + 500;
const atTop = element.scrollTop === 0;
this.streamPositionnedAtTop = false;
if (atBottom && !this.isProcessingInfiniteScroll) {
this.scrolledToBottom();
} else if (atTop) {
this.scrolledToTop();
}
}
private scrolledToTop() {
this.streamPositionnedAtTop = true;
this.loadBuffer();
}
private loadBuffer(){
if(this.bufferWasCleared) {
this.statuses.length = 0;
this.bufferWasCleared = false;
}
for (const status of this.bufferStream) {
const wrapper = new StatusWrapper(status, this.account);
this.statuses.unshift(wrapper);
}
this.bufferStream.length = 0;
}
private scrolledToBottom() {
this.isProcessingInfiniteScroll = true;
const lastStatus = this.statuses[this.statuses.length - 1];
this.mastodonService.getTimeline(this.account, this._streamElement.type, lastStatus.status.id)
.then((status: Status[]) => {
for (const s of status) {
const wrapper = new StatusWrapper(s, this.account);
this.statuses.push(wrapper);
}
})
.catch(err => {
console.error(err);
})
.then(() => {
this.isProcessingInfiniteScroll = false;
});
}
private getRegisteredAccounts(): AccountInfo[] {
var regAccounts = <AccountInfo[]>this.store.snapshot().registeredaccounts.accounts;
return regAccounts;
}
private retrieveToots(): void {
this.mastodonService.getTimeline(this.account, this._streamElement.type)
.then((results: Status[]) => {
for (const s of results) {
const wrapper = new StatusWrapper(s, this.account);
this.statuses.push(wrapper);
}
});
}
private launchWebsocket(): void {
this.websocketStreaming = this.streamingService.getStreaming(this.account, this._streamElement.type);
this.websocketStreaming.statusUpdateSubjet.subscribe((update: StatusUpdate) => {
if (update) {
if (update.type === EventEnum.update) {
if (!this.statuses.find(x => x.status.id == update.status.id)) {
if (this.streamPositionnedAtTop) {
const wrapper = new StatusWrapper(update.status, this.account);
this.statuses.unshift(wrapper);
} else {
this.bufferStream.push(update.status);
}
}
}
}
this.checkAndCleanUpStream();
});
}
private checkAndCleanUpStream(): void {
if (this.streamPositionnedAtTop && this.statuses.length > 60) {
this.statuses.length = 40;
}
if (this.bufferStream.length > 60) {
this.bufferWasCleared = true;
this.bufferStream.length = 40;
}
}
}
export class StatusWrapper {
constructor(
public status: Status,
public provider: AccountInfo
) {}
) { }
}

View File

@ -1,4 +1,4 @@
<div class="profile flexcroll">
<div class="profile">
<app-waiting-animation *ngIf="isLoading" class="waiting-icon"></app-waiting-animation>
<div *ngIf="account" class="profile-header" [ngStyle]="{'background-image':'url('+account.header+')'}">
@ -9,20 +9,23 @@
<h2 class="profile-header__fullhandle"><a href="{{account.url}}" target="_blank">@{{account.acct}}</a></h2>
</div>
</div>
<div *ngIf="account && hasNote" class="profile-description">
<app-databinded-text class="status__content" [textIsSelectable]="false" [text]="account.note" (accountSelected)="accountSelected($event)"
(hashtagSelected)="hashtagSelected($event)"></app-databinded-text>
<!-- <p innerHTML="{{account.note}}"></p> -->
</div>
<div class="profile-statuses">
<app-waiting-animation *ngIf="statusLoading" class="waiting-icon"></app-waiting-animation>
<div *ngIf="!isLoading && !statusLoading && statuses.length == 0" class="profile-no-toots">
no toots found
<div class="profile-sub-header flexcroll">
<div *ngIf="account && hasNote" class="profile-description">
<app-databinded-text class="status__content" [textIsSelectable]="false" [text]="account.note"
(accountSelected)="accountSelected($event)" (hashtagSelected)="hashtagSelected($event)"></app-databinded-text>
<!-- <p innerHTML="{{account.note}}"></p> -->
</div>
<div class="profile-statuses">
<app-waiting-animation *ngIf="statusLoading" class="waiting-icon"></app-waiting-animation>
<div *ngFor="let statusWrapper of statuses">
<app-status [statusWrapper]="statusWrapper" (browseAccount)="accountSelected($event)"></app-status>
<div *ngIf="!isLoading && !statusLoading && statuses.length == 0" class="profile-no-toots">
no toots found
</div>
<div *ngFor="let statusWrapper of statuses">
<app-status [statusWrapper]="statusWrapper" (browseAccount)="accountSelected($event)"></app-status>
</div>
</div>
</div>
</div>

View File

@ -1,7 +1,10 @@
@import "variables";
@import "commons";
$header-height: 160px;
.profile {
overflow: auto;
// overflow: auto;
height: calc(100% - 30px);
&-header {
@ -14,7 +17,7 @@
}
&__inner {
overflow: auto;
height: 160px;
height: $header-height;
background-color: rgba(0, 0, 0, .45);
}
&__avatar {
@ -38,6 +41,14 @@
}
}
&-sub-header {
overflow: auto;
height: calc(100% - #{$header-height});
// height: calc(20% - 190px);
// height: 150px;
// border: 1px solid greenyellow;
}
&-description {
padding: 10px 10px 15px 10px;
font-size: 13px;

View File

@ -33,20 +33,15 @@ export class UserProfileComponent implements OnInit {
this.loadAccount(accountName)
.then((account: Account) => {
this.account = account;
this.hasNote = account && account.note && account.note !== '<p></p>';
return this.getStatuses(this.account);
})
.catch(err => {
this.error = 'Error when retrieving account';
this.isLoading = false;
this.statusLoading = false;
console.warn(this.error);
console.error(this.error);
});
// this.account = account;
// this.hasNote = account && account.note && account.note !== '<p></p>';
// console.warn('currentAccount');
// console.warn(account);
// this.getStatuses(account);
}
constructor(
@ -76,11 +71,10 @@ export class UserProfileComponent implements OnInit {
}
this.isLoading = true;
return this.mastodonService.search(selectedAccounts[0], accountName, true)
.then((result: Results) => {
console.warn(result);
return this.toolsService.findAccount(selectedAccounts[0], accountName)
.then((result) => {
this.isLoading = false;
return result.accounts[0];
return result;
});
}

View File

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

View File

@ -19,13 +19,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): Promise<Status[]> {
const route = `https://${account.instance}${this.getTimelineRoute(type, max_id, since_id, limit)}`;
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)}`;
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): string {
private getTimelineRoute(type: StreamTypeEnum, max_id: string, since_id: string, limit: number, tag: string, list: string): string {
let route: string;
switch (type) {
case StreamTypeEnum.personnal:
@ -41,10 +41,10 @@ export class MastodonService {
route = this.apiRoutes.getDirectTimeline;
break;
case StreamTypeEnum.tag:
route = this.apiRoutes.getTagTimeline.replace('{0}', 'TODO');
route = this.apiRoutes.getTagTimeline.replace('{0}', tag);
break;
case StreamTypeEnum.list:
route = this.apiRoutes.getListTimeline.replace('{0}', 'TODO');
route = this.apiRoutes.getListTimeline.replace('{0}', list);
break;
default:
throw new Error('StreamTypeEnum not supported');
@ -112,6 +112,7 @@ export class MastodonService {
}
search(account: AccountInfo, query: string, resolve: boolean = false): Promise<Results>{
if(query[0] === '#') query = query.substr(1);
const route = `https://${account.instance}${this.apiRoutes.search}?q=${query}&resolve=${resolve}`;
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.get<Results>(route, { headers: headers }).toPromise()

View File

@ -2,18 +2,21 @@ import { Injectable } from "@angular/core";
import { Status } from "./models/mastodon.interfaces";
import { BehaviorSubject } from "rxjs";
import { ApiRoutes } from "./models/api.settings";
import { StreamTypeEnum } from "../states/streams.state";
import { StreamTypeEnum, StreamElement } from "../states/streams.state";
import { MastodonService } from "./mastodon.service";
import { AccountInfo } from "../states/accounts.state";
import { stat } from "fs";
@Injectable()
export class StreamingService {
public readonly nbStatusPerIteration: number = 20;
constructor(
private readonly mastodonService: MastodonService) { }
getStreaming(accountInfo: AccountInfo, streamType: StreamTypeEnum): StreamingWrapper {
return new StreamingWrapper(this.mastodonService, accountInfo, streamType);
getStreaming(accountInfo: AccountInfo, stream: StreamElement): StreamingWrapper {
return new StreamingWrapper(this.mastodonService, accountInfo, stream, this.nbStatusPerIteration);
}
@ -23,84 +26,68 @@ export class StreamingWrapper {
statusUpdateSubjet = new BehaviorSubject<StatusUpdate>(null);
eventSource: WebSocket;
private apiRoutes = new ApiRoutes();
private errorClosing: boolean;
private since_id: string;
private disposed: boolean;
constructor(
private readonly mastodonService: MastodonService,
private readonly accountInfo: AccountInfo,
private readonly streamType: StreamTypeEnum) {
private readonly account: AccountInfo,
private readonly stream: StreamElement,
private readonly nbStatusPerIteration: number) {
const request = this.getRequest(streamType);
const route = `wss://${accountInfo.instance}${this.apiRoutes.getStreaming}`.replace('{0}', accountInfo.token.access_token).replace('{1}', request);
const route = this.getRoute(account, stream);
this.start(route);
}
dispose(): any {
this.disposed = true;
this.eventSource.close();
}
private start(route: string) {
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.onclose = x => this.webSocketClosed(route, x);
this.eventSource.onclose = x => this.webSocketClosed(route, x);
}
private errorClosing: boolean;
private webSocketGotError(x: Event) {
console.log(x);
this.errorClosing = true;
}
private since_id: string;
private webSocketClosed(domain, x: Event) {
console.log(x);
if (this.errorClosing) {
this.pullNewStatuses(domain);
// this.mastodonService.getTimeline(this.accountInfo, this.streamType, null, this.since_id)
// .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));
// for (const s of status) {
// const update = new StatusUpdate();
// update.status = s;
// update.type = EventEnum.update;
// this.since_id = update.status.id;
// this.statusUpdateSubjet.next(update);
// }
// })
// .catch(err => {
// console.error(err);
// })
// .then(() => {
// setTimeout(() => { this.start(domain) }, 20 * 1000);
// });
this.errorClosing = false;
} else {
} else if (!this.disposed) {
setTimeout(() => { this.start(domain) }, 5000);
}
}
private pullNewStatuses(domain){
this.mastodonService.getTimeline(this.accountInfo, this.streamType, null, this.since_id)
.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));
for (const s of status) {
const update = new StatusUpdate();
update.status = s;
update.type = EventEnum.update;
this.since_id = update.status.id;
this.statusUpdateSubjet.next(update);
}
})
.catch(err => {
console.error(err);
})
.then(() => {
// setTimeout(() => { this.start(domain) }, 20 * 1000);
private pullNewStatuses(domain) {
this.mastodonService.getTimeline(this.account, this.stream.type, null, this.since_id, this.nbStatusPerIteration, this.stream.tag, this.stream.list)
.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));
for (const s of status) {
const update = new StatusUpdate();
update.status = s;
update.type = EventEnum.update;
this.since_id = update.status.id;
this.statusUpdateSubjet.next(update);
}
})
.catch(err => {
console.error(err);
})
.then(() => {
// setTimeout(() => { this.start(domain) }, 20 * 1000);
if (!this.disposed) {
setTimeout(() => { this.pullNewStatuses(domain) }, 15 * 1000);
});
}
});
}
private statusParsing(event: WebSocketEvent) {
@ -122,7 +109,17 @@ export class StreamingWrapper {
this.statusUpdateSubjet.next(newUpdate);
}
private getRequest(type: StreamTypeEnum): string {
private getRoute(account: AccountInfo, stream: StreamElement): string {
const streamingRouteType = this.getStreamingRouteType(stream.type);
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}`;
return route;
}
private getStreamingRouteType(type: StreamTypeEnum): string {
switch (type) {
case StreamTypeEnum.global:
return 'public';
@ -130,6 +127,14 @@ export class StreamingWrapper {
return 'public:local';
case StreamTypeEnum.personnal:
return 'user';
case StreamTypeEnum.directmessages:
return 'direct';
case StreamTypeEnum.tag:
return 'hashtag';
case StreamTypeEnum.list:
return 'list';
default:
throw Error('Not supported');
}
}
}

View File

@ -2,6 +2,8 @@ import { Injectable } from '@angular/core';
import { Store } from '@ngxs/store';
import { AccountInfo } from '../states/accounts.state';
import { MastodonService } from './mastodon.service';
import { Account, Results } from "./models/mastodon.interfaces";
@Injectable({
@ -9,7 +11,9 @@ import { AccountInfo } from '../states/accounts.state';
})
export class ToolsService {
constructor( private readonly store: Store) { }
constructor(
private readonly mastodonService: MastodonService,
private readonly store: Store) { }
getSelectedAccounts(): AccountInfo[] {
@ -17,4 +21,21 @@ export class ToolsService {
return regAccounts.filter(x => x.isSelected);
}
findAccount(account: AccountInfo, accountName: string): Promise<Account> {
return this.mastodonService.search(account, accountName, true)
.then((result: Results) => {
console.warn('findAccount');
console.warn(`accountName ${accountName}`);
console.warn(result);
if(accountName[0] === '@') accountName = accountName.substr(1);
const foundAccount = result.accounts.filter(
x => x.acct.toLowerCase() === accountName.toLowerCase()
|| x.acct.toLowerCase() === accountName.toLowerCase().split('@')[0]
)[0];
return foundAccount;
});
}
}

View File

@ -26,8 +26,12 @@ export class StreamsState {
}
export class StreamElement {
constructor(public type: StreamTypeEnum, public name: string, public accountId: string) {
constructor(
public type: StreamTypeEnum,
public name: string,
public accountId: string,
public tag: string,
public list: string) {
}
}

View File

@ -30,4 +30,17 @@
// &:visited {
// outline: none;
// }
}
.btn-custom-secondary {
border: none;
outline: none;
cursor: pointer;
background-color: $button-background-color;
color: $button-color;
transition: all .2s;
&:hover {
background-color: $button-background-color-hover;
color: $button-color-hover;
}
}

View File

@ -33,4 +33,11 @@ $stream-column-separator: 7px;
$stream-column-width: 320px;
$avatar-column-space: 70px;
//Bootstrap cuistomization
$enable-rounded: false;
$enable-rounded: false;
$separator-color:$color-primary;
$button-size: 30px;
$button-color: darken(white, 30);
$button-color-hover: white;
$button-background-color: $color-primary;
$button-background-color-hover: lighten($color-primary, 20);