Merge pull request #69 from NicolasConstant/feature_add-notifications

Feature add notifications
This commit is contained in:
Nicolas Constant 2019-04-02 00:48:49 -04:00 committed by GitHub
commit 068c3d4163
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 1833 additions and 237 deletions

15
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:4200",
"webRoot": "${workspaceFolder}"
}
]
}

View File

@ -1,8 +1,8 @@
{ {
"name": "sengi", "name": "sengi",
"version": "0.0.0", "version": "0.0.0",
"license": "MIT", "license": "AGPL-3.0-or-later",
"main": "main.js", "main": "main-electron.js",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",

View File

@ -50,6 +50,12 @@ import { NotificationService } from "./services/notification.service";
import { MediaViewerComponent } from './components/media-viewer/media-viewer.component'; import { MediaViewerComponent } from './components/media-viewer/media-viewer.component';
import { CreateStatusComponent } from './components/create-status/create-status.component'; import { CreateStatusComponent } from './components/create-status/create-status.component';
import { MediaComponent } from './components/create-status/media/media.component'; import { MediaComponent } from './components/create-status/media/media.component';
import { MyAccountComponent } from './components/floating-column/manage-account/my-account/my-account.component';
import { FavoritesComponent } from './components/floating-column/manage-account/favorites/favorites.component';
import { DirectMessagesComponent } from './components/floating-column/manage-account/direct-messages/direct-messages.component';
import { MentionsComponent } from './components/floating-column/manage-account/mentions/mentions.component';
import { NotificationsComponent } from './components/floating-column/manage-account/notifications/notifications.component';
import { SettingsState } from './states/settings.state';
const routes: Routes = [ const routes: Routes = [
{ path: "", redirectTo: "home", pathMatch: "full" }, { path: "", redirectTo: "home", pathMatch: "full" },
@ -89,7 +95,12 @@ const routes: Routes = [
NotificationHubComponent, NotificationHubComponent,
MediaViewerComponent, MediaViewerComponent,
CreateStatusComponent, CreateStatusComponent,
MediaComponent MediaComponent,
MyAccountComponent,
FavoritesComponent,
DirectMessagesComponent,
MentionsComponent,
NotificationsComponent
], ],
imports: [ imports: [
FontAwesomeModule, FontAwesomeModule,
@ -102,7 +113,8 @@ const routes: Routes = [
NgxsModule.forRoot([ NgxsModule.forRoot([
RegisteredAppsState, RegisteredAppsState,
AccountsState, AccountsState,
StreamsState StreamsState,
SettingsState
]), ]),
NgxsStoragePluginModule.forRoot() NgxsStoragePluginModule.forRoot()
], ],

View File

@ -129,7 +129,6 @@ describe('CreateStatusComponent', () => {
expect(result[1].length).toBeLessThanOrEqual(527); expect(result[1].length).toBeLessThanOrEqual(527);
expect(result[0]).toContain('@Lorem@ipsum.com '); expect(result[0]).toContain('@Lorem@ipsum.com ');
expect(result[1]).toContain('@Lorem@ipsum.com '); expect(result[1]).toContain('@Lorem@ipsum.com ');
console.warn(result);
}); });
}); });

View File

@ -10,7 +10,11 @@
</a> </a>
</div> </div>
<app-manage-account *ngIf="openPanel === 'manageAccount'" [account]="userAccountUsed"></app-manage-account> <app-manage-account *ngIf="openPanel === 'manageAccount'"
[account]="userAccountUsed"
(browseAccountEvent)="browseAccount($event)"
(browseHashtagEvent)="browseHashtag($event)"
(browseThreadEvent)="browseThread($event)"></app-manage-account>
<app-add-new-status *ngIf="openPanel === 'createNewStatus'"></app-add-new-status> <app-add-new-status *ngIf="openPanel === 'createNewStatus'"></app-add-new-status>
<app-add-new-account *ngIf="openPanel === 'addNewAccount'"></app-add-new-account> <app-add-new-account *ngIf="openPanel === 'addNewAccount'"></app-add-new-account>
<app-search *ngIf="openPanel === 'search'" <app-search *ngIf="openPanel === 'search'"

View File

@ -0,0 +1,3 @@
<p>
direct-messages works!
</p>

View File

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

View File

@ -0,0 +1,119 @@
import { Component, OnInit, Input, Output, EventEmitter, ViewChild, ElementRef } from '@angular/core';
import { AccountWrapper } from '../../../../models/account.models';
import { OpenThreadEvent } from '../../../../services/tools.service';
import { StatusWrapper } from '../../../../models/common.model';
import { NotificationService } from '../../../../services/notification.service';
import { MastodonService } from '../../../../services/mastodon.service';
import { StreamTypeEnum } from '../../../../states/streams.state';
import { Status } from '../../../../services/models/mastodon.interfaces';
@Component({
selector: 'app-direct-messages',
templateUrl: '../../../stream/stream-statuses/stream-statuses.component.html',
styleUrls: ['../../../stream/stream-statuses/stream-statuses.component.scss', './direct-messages.component.scss']
})
export class DirectMessagesComponent implements OnInit {
statuses: StatusWrapper[] = [];
displayError: string;
isLoading = true;
isThread = false;
hasContentWarnings = false;
@Output() browseAccountEvent = new EventEmitter<string>();
@Output() browseHashtagEvent = new EventEmitter<string>();
@Output() browseThreadEvent = new EventEmitter<OpenThreadEvent>();
private maxReached = false;
private _account: AccountWrapper;
@Input('account')
set account(acc: AccountWrapper) {
console.warn('account');
this._account = acc;
this.getDirectMessages();
}
get account(): AccountWrapper {
return this._account;
}
@ViewChild('statusstream') public statustream: ElementRef;
constructor(
private readonly notificationService: NotificationService,
private readonly mastodonService: MastodonService) { }
ngOnInit() {
}
private reset() {
this.isLoading = true;
this.statuses.length = 0;
this.maxReached = false;
}
private getDirectMessages() {
this.reset();
this.mastodonService.getTimeline(this.account.info, StreamTypeEnum.directmessages)
.then((statuses: Status[]) => {
//this.maxId = statuses[statuses.length - 1].id;
for (const s of statuses) {
const wrapper = new StatusWrapper(s, this.account.info);
this.statuses.push(wrapper);
}
})
.catch(err => {
this.notificationService.notifyHttpError(err);
})
.then(() => {
this.isLoading = false;
});
}
onScroll() {
var element = this.statustream.nativeElement as HTMLElement;
const atBottom = element.scrollHeight <= element.clientHeight + element.scrollTop + 1000;
if (atBottom) {
this.scrolledToBottom();
}
}
private scrolledToBottom() {
if (this.isLoading || this.maxReached) return;
const maxId = this.statuses[this.statuses.length - 1].status.id;
this.isLoading = true;
this.mastodonService.getTimeline(this.account.info, StreamTypeEnum.directmessages, maxId)
.then((statuses: Status[]) => {
if (statuses.length === 0) {
this.maxReached = true;
return;
}
for (const s of statuses) {
const wrapper = new StatusWrapper(s, this.account.info);
this.statuses.push(wrapper);
}
})
.catch(err => {
this.notificationService.notifyHttpError(err);
})
.then(() => {
this.isLoading = false;
});
}
browseAccount(accountName: string): void {
this.browseAccountEvent.next(accountName);
}
browseHashtag(hashtag: string): void {
this.browseHashtagEvent.next(hashtag);
}
browseThread(openThreadEvent: OpenThreadEvent): void {
this.browseThreadEvent.next(openThreadEvent);
}
}

View File

@ -0,0 +1,3 @@
<p>
favorites works!
</p>

View File

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

View File

@ -0,0 +1,123 @@
import { Component, OnInit, Output, EventEmitter, Input, ViewChild, ElementRef } from '@angular/core';
import { StatusWrapper } from '../../../../models/common.model';
import { OpenThreadEvent } from '../../../../services/tools.service';
import { AccountWrapper } from '../../../../models/account.models';
import { MastodonService, FavoriteResult } from '../../../../services/mastodon.service';
import { Status } from '../../../../services/models/mastodon.interfaces';
import { NotificationService } from '../../../../services/notification.service';
import { resetCompiledComponents } from '@angular/core/src/render3/jit/module';
@Component({
selector: 'app-favorites',
templateUrl: '../../../stream/stream-statuses/stream-statuses.component.html',
styleUrls: ['../../../stream/stream-statuses/stream-statuses.component.scss', './favorites.component.scss']
})
export class FavoritesComponent implements OnInit {
statuses: StatusWrapper[] = [];
displayError: string;
isLoading = true;
isThread = false;
hasContentWarnings = false;
@Output() browseAccountEvent = new EventEmitter<string>();
@Output() browseHashtagEvent = new EventEmitter<string>();
@Output() browseThreadEvent = new EventEmitter<OpenThreadEvent>();
private maxReached = false;
private maxId: string;
private _account: AccountWrapper;
@Input('account')
set account(acc: AccountWrapper) {
this._account = acc;
this.getFavorites();
}
get account(): AccountWrapper {
return this._account;
}
@ViewChild('statusstream') public statustream: ElementRef;
constructor(
private readonly notificationService: NotificationService,
private readonly mastodonService: MastodonService) { }
ngOnInit() {
}
private reset(){
this.isLoading = true;
this.statuses.length = 0;
this.maxReached = false;
this.maxId = null;
}
private getFavorites() {
this.reset();
this.mastodonService.getFavorites(this.account.info)
.then((result: FavoriteResult) => {
this.maxId = result.max_id;
for (const s of result.favorites) {
const wrapper = new StatusWrapper(s, this.account.info);
this.statuses.push(wrapper);
}
})
.catch(err => {
this.notificationService.notifyHttpError(err);
})
.then(() => {
this.isLoading = false;
});
}
onScroll() {
var element = this.statustream.nativeElement as HTMLElement;
const atBottom = element.scrollHeight <= element.clientHeight + element.scrollTop + 1000;
if (atBottom) {
this.scrolledToBottom();
}
}
private scrolledToBottom() {
if (this.isLoading || this.maxReached) return;
this.isLoading = true;
this.mastodonService.getFavorites(this.account.info, this.maxId)
.then((result: FavoriteResult) => {
const statuses = result.favorites;
if (statuses.length === 0 || !this.maxId) {
this.maxReached = true;
return;
}
this.maxId = result.max_id;
for (const s of statuses) {
const wrapper = new StatusWrapper(s, this.account.info);
this.statuses.push(wrapper);
}
})
.catch(err => {
this.notificationService.notifyHttpError(err);
})
.then(() => {
this.isLoading = false;
});
}
browseAccount(accountName: string): void {
this.browseAccountEvent.next(accountName);
}
browseHashtag(hashtag: string): void {
this.browseHashtagEvent.next(hashtag);
}
browseThread(openThreadEvent: OpenThreadEvent): void {
this.browseThreadEvent.next(openThreadEvent);
}
}

View File

@ -1,28 +1,53 @@
<div class="panel"> <div class="panel">
<h3 class="panel__title">Manage Account</h3> <h3 class="panel__title">Manage Account</h3>
<div class="account-editor__display-avatar"> <div class="account__header">
<img class="account-editor__avatar" src="{{account.avatar}}" title="{{ account.info.id }} " /> <img class="account__avatar" src="{{account.avatar}}" title="{{ account.info.id }} " />
<!-- <a href class="account__header--button"><fa-icon [icon]="faUserPlus"></fa-icon></a> -->
<a href class="account__header--button" title="favorites" (click)="loadSubPanel('favorites')"
[ngClass]="{ 'account__header--button--selected': subPanel === 'favorites' }">
<fa-icon [icon]="faStar"></fa-icon>
</a>
<a href class="account__header--button" title="DM" (click)="loadSubPanel('dm')"
[ngClass]="{ 'account__header--button--selected': subPanel === 'dm' }">
<fa-icon [icon]="faEnvelope"></fa-icon>
</a>
<a href class="account__header--button" title="mentions" (click)="loadSubPanel('mentions')"
[ngClass]="{ 'account__header--button--selected': subPanel === 'mentions', 'account__header--button--notification': hasMentions }">
<fa-icon [icon]="faAt"></fa-icon>
</a>
<a href class="account__header--button" title="notifications" (click)="loadSubPanel('notifications')"
[ngClass]="{ 'account__header--button--selected': subPanel === 'notifications',
'account__header--button--notification': hasNotifications }">
<fa-icon [icon]="faBell"></fa-icon>
</a>
<a href class="account__header--button" title="account" (click)="loadSubPanel('account')"
[ngClass]="{ 'account__header--button--selected': subPanel === 'account' }">
<fa-icon [icon]="faUser"></fa-icon>
</a>
</div> </div>
<h4 class="account__label">add column:</h4> <app-direct-messages class="account__body" *ngIf="subPanel === 'dm'"
[account]="account"
<a class="account__link account__blue" href *ngFor="let stream of availableStreams" (click)="addStream(stream)"> (browseAccountEvent)="browseAccount($event)"
{{ stream.name }} (browseHashtagEvent)="browseHashtag($event)"
</a> (browseThreadEvent)="browseThread($event)"></app-direct-messages>
<!-- <a class="add-column__link" href> <app-favorites class="account__body" *ngIf="subPanel === 'favorites'"
Global Timeline [account]="account"
</a> (browseAccountEvent)="browseAccount($event)"
<a class="add-column__link" href> (browseHashtagEvent)="browseHashtag($event)"
Personnal Timeline (browseThreadEvent)="browseThread($event)"></app-favorites>
</a> <app-mentions class="account__body" *ngIf="subPanel === 'mentions'"
<a class="add-column__link" href> [account]="account"
Lists, Favs, Activitires, etc (browseAccountEvent)="browseAccount($event)"
</a> --> (browseHashtagEvent)="browseHashtag($event)"
(browseThreadEvent)="browseThread($event)"></app-mentions>
<app-my-account class="account__body" *ngIf="subPanel === 'account'"
<h4 class="account__label account__margin-top">remove account from sengi:</h4> [account]="account"></app-my-account>
<a class="account__link account__red" href (click)="removeAccount()"> <app-notifications class="account__body" *ngIf="subPanel === 'notifications'"
Delete [account]="account"
</a> (browseAccountEvent)="browseAccount($event)"
(browseHashtagEvent)="browseHashtag($event)"
(browseThreadEvent)="browseThread($event)"></app-notifications>
</div> </div>

View File

@ -1,67 +1,60 @@
@import "variables"; @import "variables";
@import "panel"; @import "panel";
.account-editor { @import "commons";
// padding: 10px 10px 0 7px; $account-header-height: 60px;
// font-size: $small-font-size; .panel {
// &__title { padding-left: 0px;
// font-size: 13px; padding-right: 0px;
// text-transform: uppercase;
// margin: 6px 0 12px 0;
// }
&__display-avatar {
text-align: center;
margin-bottom: 30px;
}
&__avatar {
// display: block;
width: 75px;
border-radius: 50px;
transform: translateX(15px); // margin: auto;
}
} }
.account { .account {
&__label { &__header {
// text-decoration: underline; // padding-left: 10px;
font-size: $small-font-size; padding-left: 5px;
margin-left: 5px; padding-right: 10px;
color: $font-color-secondary; padding-bottom: 5px;
} height: $account-header-height; //border-top: 1px solid #222736;
&__margin-top { border-bottom: 1px solid #222736;
margin-top: 25px; &--button {
} // outline: 1px greenyellow solid;
&__link { margin-top: 20px;
text-decoration: none; width: 35px;
display: block; // width: calc(100% - 20px); height: 35px;
width: 100%; // height: 30px; float: right;
padding: 5px 10px; // border: solid 1px black; margin-left: 5px;
&:not(:last-child) { font-size: 22px;
margin-bottom: 5px; font-size: 20px;
color: $font-link-primary;
padding-left: 6px;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
transition: all .2s;
&:hover {
color: $font-link-primary-hover;
}
&--selected {
color: whitesmoke;
&:hover {
color: whitesmoke;
}
}
&--notification {
color: rgb(250, 152, 41);
&:hover {
color: rgb(255, 185, 106);
}
}
} }
} }
&__mid-link { &__avatar {
text-decoration: none; width: 50px;
display: block; // width: calc(100% - 20px); border-radius: 3px;
width: 45%; // height: 30px;
padding: 5px 10px; // border: solid 1px black;
&:not(:last-child) {
margin-bottom: 5px;
}
} }
&__blue { &__body {
background-color: $color-primary; overflow: auto;
color: #fff; height: calc(100% - #{$account-header-height} - 31px);
&:hover { display: block;
background-color: lighten($color-primary, 15); font-size: $default-font-size;
}
}
&__red {
$red-button-color: rgb(65, 3, 3);
background-color: $red-button-color;
color: #fff;
&:hover {
background-color: lighten($red-button-color, 15);
}
} }
} }

View File

@ -1,48 +1,85 @@
import { Component, OnInit, Input } from '@angular/core'; import { Component, OnInit, OnDestroy, Input, Output, EventEmitter } from '@angular/core';
import { StreamElement, StreamTypeEnum, AddStream, RemoveAllStreams } from '../../../states/streams.state'; import { faAt, faUserPlus } from "@fortawesome/free-solid-svg-icons";
import { Store } from '@ngxs/store'; import { faBell, faEnvelope, faUser, faStar } from "@fortawesome/free-regular-svg-icons";
import { AccountsStateModel, AccountInfo, RemoveAccount } from '../../../states/accounts.state'; import { Subscription } from 'rxjs';
import { AccountWrapper } from '../../../models/account.models'; import { AccountWrapper } from '../../../models/account.models';
import { NavigationService } from '../../../services/navigation.service'; import { UserNotificationService, UserNotification } from '../../../services/user-notification.service';
import { NotificationService } from '../../../services/notification.service'; import { OpenThreadEvent } from '../../../services/tools.service';
@Component({ @Component({
selector: 'app-manage-account', selector: 'app-manage-account',
templateUrl: './manage-account.component.html', templateUrl: './manage-account.component.html',
styleUrls: ['./manage-account.component.scss'] styleUrls: ['./manage-account.component.scss']
}) })
export class ManageAccountComponent implements OnInit { export class ManageAccountComponent implements OnInit, OnDestroy {
@Input() account: AccountWrapper; faAt = faAt;
faBell = faBell;
faEnvelope = faEnvelope;
faUser = faUser;
faStar = faStar;
faUserPlus = faUserPlus;
availableStreams: StreamElement[] = []; subPanel = 'account';
hasNotifications = false;
hasMentions = false;
@Output() browseAccountEvent = new EventEmitter<string>();
@Output() browseHashtagEvent = new EventEmitter<string>();
@Output() browseThreadEvent = new EventEmitter<OpenThreadEvent>();
@Input('account')
set account(acc: AccountWrapper) {
this._account = acc;
this.checkNotifications();
}
get account(): AccountWrapper {
return this._account;
}
private userNotificationServiceSub: Subscription;
private _account: AccountWrapper;
constructor( constructor(
private readonly store: Store, private readonly userNotificationService: UserNotificationService) { }
private readonly navigationService: NavigationService,
private notificationService: NotificationService) { }
ngOnInit() { ngOnInit() {
const instance = this.account.info.instance;
this.availableStreams.length = 0;
this.availableStreams.push(new StreamElement(StreamTypeEnum.global, 'Federated Timeline', this.account.info.id, null, null, instance));
this.availableStreams.push(new StreamElement(StreamTypeEnum.local, 'Local Timeline', this.account.info.id, null, null, instance));
this.availableStreams.push(new StreamElement(StreamTypeEnum.personnal, 'Home', this.account.info.id, null, null, instance));
} }
addStream(stream: StreamElement): boolean { ngOnDestroy(): void {
if (stream) { this.userNotificationServiceSub.unsubscribe();
this.store.dispatch([new AddStream(stream)]).toPromise() }
.then(() => {
this.notificationService.notify(`stream added`, false); private checkNotifications(){
}); if(this.userNotificationServiceSub){
this.userNotificationServiceSub.unsubscribe();
} }
this.userNotificationServiceSub = this.userNotificationService.userNotifications.subscribe((userNotifications: UserNotification[]) => {
const userNotification = userNotifications.find(x => x.account.id === this.account.info.id);
if(userNotification){
this.hasNotifications = userNotification.hasNewNotifications;
this.hasMentions = userNotification.hasNewMentions;
}
});
}
loadSubPanel(subpanel: string): boolean {
this.subPanel = subpanel;
return false; return false;
} }
removeAccount(): boolean { browseAccount(accountName: string): void {
const accountId = this.account.info.id; this.browseAccountEvent.next(accountName);
this.store.dispatch([new RemoveAllStreams(accountId), new RemoveAccount(accountId)]); }
this.navigationService.closePanel();
return false; browseHashtag(hashtag: string): void {
this.browseHashtagEvent.next(hashtag);
}
browseThread(openThreadEvent: OpenThreadEvent): void {
this.browseThreadEvent.next(openThreadEvent);
} }
} }

View File

@ -0,0 +1,3 @@
<p>
mentions works!
</p>

View File

@ -0,0 +1,7 @@
@import "variables";
@import "commons";
@import "mixins";
.stream-toots {
background-color: $column-background;
}

View File

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

View File

@ -0,0 +1,137 @@
import { Component, OnInit, OnDestroy, Input, ViewChild, ElementRef, Output, EventEmitter } from '@angular/core';
import { Subscription } from 'rxjs';
import { AccountWrapper } from '../../../../models/account.models';
import { UserNotificationService, UserNotification } from '../../../../services/user-notification.service';
import { StatusWrapper } from '../../../../models/common.model';
import { Status, Notification } from '../../../../services/models/mastodon.interfaces';
import { MastodonService } from '../../../../services/mastodon.service';
import { NotificationService } from '../../../../services/notification.service';
import { ToolsService, OpenThreadEvent } from '../../../../services/tools.service';
@Component({
selector: 'app-mentions',
templateUrl: '../../../stream/stream-statuses/stream-statuses.component.html',
styleUrls: ['../../../stream/stream-statuses/stream-statuses.component.scss', './mentions.component.scss']
})
export class MentionsComponent implements OnInit, OnDestroy {
statuses: StatusWrapper[] = [];
displayError: string;
isLoading = false;
isThread = false;
hasContentWarnings = false;
@Output() browseAccountEvent = new EventEmitter<string>();
@Output() browseHashtagEvent = new EventEmitter<string>();
@Output() browseThreadEvent = new EventEmitter<OpenThreadEvent>();
@Input('account')
set account(acc: AccountWrapper) {
console.warn('account');
this._account = acc;
this.loadMentions();
const accountSettings = this.toolsService.getAccountSettings(acc.info);
console.warn(accountSettings);
}
get account(): AccountWrapper {
return this._account;
}
@ViewChild('statusstream') public statustream: ElementRef;
private maxReached = false;
private _account: AccountWrapper;
private userNotificationServiceSub: Subscription;
private lastId: string;
constructor(
private readonly toolsService: ToolsService,
private readonly notificationService: NotificationService,
private readonly userNotificationService: UserNotificationService,
private readonly mastodonService: MastodonService) {
}
ngOnInit() {
}
ngOnDestroy(): void {
if(this.userNotificationServiceSub){
this.userNotificationServiceSub.unsubscribe();
}
}
private loadMentions(){
if(this.userNotificationServiceSub){
this.userNotificationServiceSub.unsubscribe();
}
this.statuses.length = 0;
this.userNotificationService.markMentionsAsRead(this.account.info);
this.userNotificationServiceSub = this.userNotificationService.userNotifications.subscribe((userNotifications: UserNotification[]) => {
this.statuses.length = 0; //TODO: don't reset, only add the new ones
const userNotification = userNotifications.find(x => x.account.id === this.account.info.id);
if(userNotification && userNotification.mentions){
userNotification.mentions.forEach((mention: Status) => {
const statusWrapper = new StatusWrapper(mention, this.account.info);
this.statuses.push(statusWrapper);
});
}
this.lastId = userNotification.lastId;
this.userNotificationService.markMentionsAsRead(this.account.info);
});
}
onScroll() {
var element = this.statustream.nativeElement as HTMLElement;
const atBottom = element.scrollHeight <= element.clientHeight + element.scrollTop + 1000;
if (atBottom) {
this.scrolledToBottom();
}
}
private scrolledToBottom() {
if (this.isLoading || this.maxReached || this.statuses.length === 0) return;
this.isLoading = true;
this.mastodonService.getNotifications(this.account.info, ['follow', 'favourite', 'reblog'], this.lastId)
.then((result: Notification[]) => {
const statuses = result.map(x => x.status);
if (statuses.length === 0) {
this.maxReached = true;
return;
}
for (const s of statuses) {
const wrapper = new StatusWrapper(s, this.account.info);
this.statuses.push(wrapper);
}
this.lastId = result[result.length - 1].id;
})
.catch(err => {
this.notificationService.notifyHttpError(err);
})
.then(() => {
this.isLoading = false;
});
}
browseAccount(accountName: string): void {
this.browseAccountEvent.next(accountName);
}
browseHashtag(hashtag: string): void {
this.browseHashtagEvent.next(hashtag);
}
browseThread(openThreadEvent: OpenThreadEvent): void {
this.browseThreadEvent.next(openThreadEvent);
}
}

View File

@ -0,0 +1,10 @@
<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)">
{{ stream.name }}
</a>
<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
</a>
</div>

View File

@ -0,0 +1,50 @@
@import "variables";
@import "commons";
.my-account {
transition: all .2s;
&__body {
overflow: auto;
height: calc(100%);
// width: calc(100%);
padding-left: 10px;
padding-right: 10px;
font-size: $small-font-size;
padding-bottom: 20px;
outline: 1px dotted greenyellow;
}
&__label {
// text-decoration: underline;
font-size: $small-font-size;
margin-top: 10px;
margin-left: 5px;
color: $font-color-secondary;
}
&__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;
}
}
&__margin-top {
margin-top: 25px;
}
&__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;
color: #fff;
&:hover {
background-color: lighten($red-button-color, 15);
}
}
}

View File

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

View File

@ -0,0 +1,50 @@
import { Component, OnInit, Input } from '@angular/core';
import { Store } from '@ngxs/store';
import { NotificationService } from '../../../../services/notification.service';
import { StreamElement, StreamTypeEnum, AddStream, RemoveAllStreams } from '../../../../states/streams.state';
import { AccountWrapper } from '../../../../models/account.models';
import { RemoveAccount } from '../../../../states/accounts.state';
import { NavigationService } from '../../../../services/navigation.service';
@Component({
selector: 'app-my-account',
templateUrl: './my-account.component.html',
styleUrls: ['./my-account.component.scss']
})
export class MyAccountComponent implements OnInit {
availableStreams: StreamElement[] = [];
@Input() account: AccountWrapper;
constructor(
private readonly store: Store,
private readonly navigationService: NavigationService,
private notificationService: NotificationService) { }
ngOnInit() {
const instance = this.account.info.instance;
this.availableStreams.length = 0;
this.availableStreams.push(new StreamElement(StreamTypeEnum.global, 'Federated Timeline', this.account.info.id, null, null, instance));
this.availableStreams.push(new StreamElement(StreamTypeEnum.local, 'Local Timeline', this.account.info.id, null, null, instance));
this.availableStreams.push(new StreamElement(StreamTypeEnum.personnal, 'Home', this.account.info.id, null, null, instance));
}
addStream(stream: StreamElement): boolean {
if (stream) {
this.store.dispatch([new AddStream(stream)]).toPromise()
.then(() => {
this.notificationService.notify(`stream added`, false);
});
}
return false;
}
removeAccount(): boolean {
const accountId = this.account.info.id;
this.store.dispatch([new RemoveAllStreams(accountId), new RemoveAccount(accountId)]);
this.navigationService.closePanel();
return false;
}
}

View File

@ -0,0 +1,45 @@
<div class="stream flexcroll" #statusstream (scroll)="onScroll()">
<div class="stream__notification" *ngFor="let notification of notifications">
<!-- <div *ngIf="notification.type === 'favourite'">
<div class="stream__notification--icon">
<fa-icon class="favorite" [icon]="faStar"></fa-icon>
</div>
<div class="stream__notification--label">
<a href class="stream__link">{{ notification.account.username }}</a> favorited your status
</div>
</div>
<div *ngIf="notification.type === 'reblog'">
<div class="stream__notification--icon">
<fa-icon class="boost" [icon]="faRetweet"></fa-icon>
</div>
<div class="stream__notification--label">
<a href class="stream__link">{{ notification.account.username }}</a> boosted your status
</div>
</div> -->
<div *ngIf="notification.type === 'follow'">
<div class="stream__notification--icon">
<fa-icon class="followed" [icon]="faUserPlus"></fa-icon>
</div>
<div class="stream__notification--label">
<a href class="stream__link"
(click)="openAccount(notification.account)">{{ notification.account.display_name }}</a> followed
you!
</div>
<a href (click)="openAccount(notification.account)" class="follow-account" title="{{notification.account.acct}}">
<img class="follow-account__avatar" src="{{ notification.account.avatar }}" />
<span class="follow-account__display-name" >{{ notification.account.display_name }}</span>
<span class="follow-account__acct">@{{ notification.account.acct }}</span>
</a>
</div>
<app-status *ngIf="notification.status" class="stream__status" [statusWrapper]="notification.status"
[notificationAccount]="notification.account" [notificationType]="notification.type"
(browseAccountEvent)="browseAccount($event)" (browseHashtagEvent)="browseHashtag($event)"
(browseThreadEvent)="browseThread($event)"></app-status>
</div>
<app-waiting-animation *ngIf="isLoading" class="waiting-icon"></app-waiting-animation>
</div>

View File

@ -0,0 +1,90 @@
@import "variables";
@import "commons";
@import "mixins";
.stream {
height: calc(100%);
width: calc(100%);
overflow: auto;
background-color: $column-background;
&__error {
padding: 20px 20px 0 20px;
color: rgb(255, 113, 113);
}
&__notification {
position: relative;
&--icon {
position: absolute;
top: 5px;
left: 43px;
text-align: center;
width: 20px;
// outline: 1px dotted greenyellow;
}
&--label {
margin: 0 10px 0 $avatar-column-space;
padding-top: 5px;
}
&:not(:last-child) {
border: solid #06070b;
border-width: 0 0 1px 0;
}
}
&__link {
color: $status-links-color;
}
&__status {
display: block;
// opacity: 0.65;
}
}
.followed {
color: $boost-color;
}
.follow-account {
padding: 5px;
height: 60px;
width: calc(100%);
overflow: hidden;
display: block;
position: relative;
text-decoration: none;
&__avatar {
float: left;
margin: 0 0 0 10px;
width: 45px;
height: 45px;
border-radius: 2px;
}
$acccount-info-left: 70px;
&__display-name {
position: absolute;
top: 7px;
left: $acccount-info-left;
color: whitesmoke;
}
&:hover &__display-name {
text-decoration: underline;
}
&__acct {
position: absolute;
top: 27px;
left: $acccount-info-left;
font-size: 13px;
color: $status-links-color;
}
}

View File

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

View File

@ -0,0 +1,159 @@
import { Component, OnInit, Input, ViewChild, ElementRef, OnDestroy, Output, EventEmitter } from '@angular/core';
import { Subscription } from 'rxjs';
import { faStar, faUserPlus, faRetweet } from "@fortawesome/free-solid-svg-icons";
import { faStar as faStar2 } from "@fortawesome/free-regular-svg-icons";
import { AccountWrapper } from '../../../../models/account.models';
import { UserNotificationService, UserNotification } from '../../../../services/user-notification.service';
import { StatusWrapper } from '../../../../models/common.model';
import { Notification, Account } from '../../../../services/models/mastodon.interfaces';
import { MastodonService } from '../../../../services/mastodon.service';
import { NotificationService } from '../../../../services/notification.service';
import { AccountInfo } from '../../../../states/accounts.state';
import { OpenThreadEvent } from '../../../../services/tools.service';
@Component({
selector: 'app-notifications',
templateUrl: './notifications.component.html',
styleUrls: ['./notifications.component.scss']
})
export class NotificationsComponent implements OnInit, OnDestroy {
faUserPlus = faUserPlus;
// faStar = faStar;
// faRetweet = faRetweet;
notifications: NotificationWrapper[] = [];
isLoading = false;
@Output() browseAccountEvent = new EventEmitter<string>();
@Output() browseHashtagEvent = new EventEmitter<string>();
@Output() browseThreadEvent = new EventEmitter<OpenThreadEvent>();
@Input('account')
set account(acc: AccountWrapper) {
this._account = acc;
this.loadNotifications();
}
get account(): AccountWrapper {
return this._account;
}
@ViewChild('statusstream') public statustream: ElementRef;
private maxReached = false;
private _account: AccountWrapper;
private userNotificationServiceSub: Subscription;
private lastId: string;
constructor(
private readonly notificationService: NotificationService,
private readonly userNotificationService: UserNotificationService,
private readonly mastodonService: MastodonService) { }
ngOnInit() {
}
ngOnDestroy(): void {
if(this.userNotificationServiceSub){
this.userNotificationServiceSub.unsubscribe();
}
}
private loadNotifications(){
if(this.userNotificationServiceSub){
this.userNotificationServiceSub.unsubscribe();
}
this.notifications.length = 0;
this.userNotificationService.markNotificationAsRead(this.account.info);
this.userNotificationServiceSub = this.userNotificationService.userNotifications.subscribe((userNotifications: UserNotification[]) => {
this.notifications.length = 0; //TODO: don't reset, only add the new ones
const userNotification = userNotifications.find(x => x.account.id === this.account.info.id);
if(userNotification && userNotification.notifications){
userNotification.notifications.forEach((notification: Notification) => {
const notificationWrapper = new NotificationWrapper(notification, this.account.info);
this.notifications.push(notificationWrapper);
});
}
this.lastId = userNotification.lastId;
this.userNotificationService.markNotificationAsRead(this.account.info);
});
}
onScroll() {
var element = this.statustream.nativeElement as HTMLElement;
const atBottom = element.scrollHeight <= element.clientHeight + element.scrollTop + 1000;
if (atBottom) {
this.scrolledToBottom();
}
}
private scrolledToBottom() {
if (this.isLoading || this.maxReached || this.notifications.length === 0) return;
this.isLoading = true;
this.mastodonService.getNotifications(this.account.info, ['mention'], this.lastId)
.then((notifications: Notification[]) => {
if (notifications.length === 0) {
this.maxReached = true;
return;
}
for (const s of notifications) {
const wrapper = new NotificationWrapper(s, this.account.info);
this.notifications.push(wrapper);
}
this.lastId = notifications[notifications.length - 1].id;
})
.catch(err => {
this.notificationService.notifyHttpError(err);
})
.then(() => {
this.isLoading = false;
});
}
openAccount(account: Account): boolean {
let accountName = account.acct;
if (!accountName.includes('@'))
accountName += `@${account.url.replace('https://', '').split('/')[0]}`;
this.browseAccountEvent.next(accountName);
return false;
}
browseAccount(accountName: string): void {
this.browseAccountEvent.next(accountName);
}
browseHashtag(hashtag: string): void {
this.browseHashtagEvent.next(hashtag);
}
browseThread(openThreadEvent: OpenThreadEvent): void {
this.browseThreadEvent.next(openThreadEvent);
}
}
class NotificationWrapper {
constructor(notification: Notification, provider: AccountInfo) {
this.type = notification.type;
switch(this.type){
case 'mention':
case 'reblog':
case 'favourite':
this.status= new StatusWrapper(notification.status, provider);
break;
}
this.account = notification.account;
}
account: Account;
status: StatusWrapper;
type: 'mention' | 'reblog' | 'favourite' | 'follow';
}

View File

@ -1,4 +1,5 @@
<a class="account-icon" <a class="account-icon"
href title="{{ account.info.id }}" (click)="toogleAccount()" (contextmenu)="openMenu()"> href title="{{ account.info.id }}" (click)="toogleAccount()" (contextmenu)="openMenu()">
<img class="account-icon__avatar" [class.account-icon__avatar--selected]="account.info.isSelected" src="{{ account.avatar }}" /> <span class="hasActivity" *ngIf="account.hasActivityNotifications">new</span>
<img class="account-icon__avatar" [class.account-icon__avatar--selected]="account.info.isSelected" src="{{ account.avatar }}" />
</a> </a>

View File

@ -1,45 +1,98 @@
.account-icon { .account-icon {
display: inline-block; display: inline-block;
width: 50px; width: 50px; // padding-top: 4px;
// padding-top: 4px;
// margin-left: 5px; // margin-left: 5px;
margin: 0 0 5px 5px; margin: 0 0 5px 5px;
&__avatar { &__avatar {
border-radius: 50%;
border-radius: 2px; border-radius: 2px;
width: 40px; width: 40px;
opacity: .3; opacity: .3;
transition: all .2s; transition: all .2s;
&:hover { &:hover {
filter: alpha(opacity=50); filter: alpha(opacity=50);
opacity: .5; opacity: .5;
} }
&--selected { &--selected {
// border-radius: 20%; // border-radius: 20%;
filter: alpha(opacity=100); filter: alpha(opacity=100);
opacity: 1; opacity: 1;
&:hover { &:hover {
filter: alpha(opacity=100); filter: alpha(opacity=100);
opacity: 1; opacity: 1;
} }
} }
} }
}
// & a { @keyframes flickerAnimation {
// margin-left: 4px; 0% {
// /*margin-top: 4px;*/ opacity: 0;
// } }
// & img { 50% {
// width: 40px; opacity: 1;
// border-radius: 50%; }
// } 100% {
opacity: 0;
}
}
@-o-keyframes flickerAnimation {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@-moz-keyframes flickerAnimation {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@-webkit-keyframes flickerAnimation {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.hasActivity {
-webkit-animation: flickerAnimation 2s infinite;
-moz-animation: flickerAnimation 2s infinite;
-o-animation: flickerAnimation 2s infinite;
animation: flickerAnimation 2s infinite;
border-radius: 2px;
width: 40px;
height: 40px;
position: absolute;
border: 2px solid orange;
z-index: 20;
color: orange;
font-size: 10px;
font-style: italic;
padding: 23px 0 0 3px;
background: rgba(0,0,0, .55);
&:hover {
color: orange;
}
} }

View File

@ -1,28 +1,30 @@
import { Component, OnInit, Input, EventEmitter, Output } from '@angular/core'; import { Component, OnInit, Input, EventEmitter, Output } from '@angular/core';
import { AccountWrapper } from '../../../models/account.models'; import { AccountWrapper } from '../../../models/account.models';
import { AccountWithNotificationWrapper } from '../left-side-bar.component';
@Component({ @Component({
selector: 'app-account-icon', selector: 'app-account-icon',
templateUrl: './account-icon.component.html', templateUrl: './account-icon.component.html',
styleUrls: ['./account-icon.component.scss'] styleUrls: ['./account-icon.component.scss']
}) })
export class AccountIconComponent implements OnInit { export class AccountIconComponent implements OnInit {
@Input() account: AccountWrapper; @Input() account: AccountWithNotificationWrapper;
@Output() toogleAccountNotify = new EventEmitter<AccountWrapper>(); @Output() toogleAccountNotify = new EventEmitter<AccountWrapper>();
@Output() openMenuNotify = new EventEmitter<AccountWrapper>(); @Output() openMenuNotify = new EventEmitter<AccountWrapper>();
constructor() { } constructor() { }
ngOnInit() { ngOnInit() {
} }
toogleAccount(): boolean { toogleAccount(): boolean {
this.toogleAccountNotify.emit(this.account); this.toogleAccountNotify.emit(this.account);
return false; return false;
} }
openMenu(): boolean { openMenu(): boolean {
this.openMenuNotify.emit(this.account); this.openMenuNotify.emit(this.account);
return false; return false;
} }
} }

View File

@ -10,6 +10,7 @@ import { AccountInfo, SelectAccount } from "../../states/accounts.state";
import { NavigationService, LeftPanelType } from "../../services/navigation.service"; import { NavigationService, LeftPanelType } from "../../services/navigation.service";
import { MastodonService } from "../../services/mastodon.service"; import { MastodonService } from "../../services/mastodon.service";
import { NotificationService } from "../../services/notification.service"; import { NotificationService } from "../../services/notification.service";
import { UserNotificationService, UserNotification } from '../../services/user-notification.service';
@Component({ @Component({
selector: "app-left-side-bar", selector: "app-left-side-bar",
@ -19,14 +20,15 @@ import { NotificationService } from "../../services/notification.service";
export class LeftSideBarComponent implements OnInit, OnDestroy { export class LeftSideBarComponent implements OnInit, OnDestroy {
faCommentAlt = faCommentAlt; faCommentAlt = faCommentAlt;
accounts: AccountWrapper[] = []; accounts: AccountWithNotificationWrapper[] = [];
hasAccounts: boolean; hasAccounts: boolean;
private accounts$: Observable<AccountInfo[]>; private accounts$: Observable<AccountInfo[]>;
// private loadedAccounts: { [index: string]: AccountInfo } = {}; private accountSub: Subscription;
private sub: Subscription; private notificationSub: Subscription;
constructor( constructor(
private readonly userNotificationServiceService: UserNotificationService,
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService,
private readonly navigationService: NavigationService, private readonly navigationService: NavigationService,
private readonly mastodonService: MastodonService, private readonly mastodonService: MastodonService,
@ -37,7 +39,7 @@ export class LeftSideBarComponent implements OnInit, OnDestroy {
private currentLoading: number; private currentLoading: number;
ngOnInit() { ngOnInit() {
this.accounts$.subscribe((accounts: AccountInfo[]) => { this.accountSub = this.accounts$.subscribe((accounts: AccountInfo[]) => {
if (accounts) { if (accounts) {
//Update and Add //Update and Add
for (let acc of accounts) { for (let acc of accounts) {
@ -45,8 +47,9 @@ export class LeftSideBarComponent implements OnInit, OnDestroy {
if (previousAcc) { if (previousAcc) {
previousAcc.info.isSelected = acc.isSelected; previousAcc.info.isSelected = acc.isSelected;
} else { } else {
const accWrapper = new AccountWrapper(); const accWrapper = new AccountWithNotificationWrapper();
accWrapper.info = acc; accWrapper.info = acc;
this.accounts.push(accWrapper); this.accounts.push(accWrapper);
this.mastodonService.retrieveAccountDetails(acc) this.mastodonService.retrieveAccountDetails(acc)
@ -61,17 +64,31 @@ export class LeftSideBarComponent implements OnInit, OnDestroy {
//Delete //Delete
const deletedAccounts = this.accounts.filter(x => accounts.findIndex(y => y.id === x.info.id) === -1); const deletedAccounts = this.accounts.filter(x => accounts.findIndex(y => y.id === x.info.id) === -1);
for(let delAcc of deletedAccounts){ for (let delAcc of deletedAccounts) {
this.accounts = this.accounts.filter(x => x.info.id !== delAcc.info.id); this.accounts = this.accounts.filter(x => x.info.id !== delAcc.info.id);
} }
this.hasAccounts = this.accounts.length > 0; this.hasAccounts = this.accounts.length > 0;
} }
}); });
this.notificationSub = this.userNotificationServiceService.userNotifications.subscribe((notifications: UserNotification[]) => {
notifications.forEach((notification: UserNotification) => {
const acc = this.accounts.find(x => x.info.id === notification.account.id);
if(acc){
acc.hasActivityNotifications = notification.hasNewMentions || notification.hasNewNotifications;
}
});
console.warn('new notifications');
console.warn(notifications);
});
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.sub.unsubscribe(); this.accountSub.unsubscribe();
this.notificationSub.unsubscribe();
} }
onToogleAccountNotify(acc: AccountWrapper) { onToogleAccountNotify(acc: AccountWrapper) {
@ -102,3 +119,14 @@ export class LeftSideBarComponent implements OnInit, OnDestroy {
return false; return false;
} }
} }
export class AccountWithNotificationWrapper extends AccountWrapper {
// constructor(accountWrapper: AccountWrapper) {
// super();
// this.avatar = accountWrapper.avatar;
// this.info = accountWrapper.info;
// }
hasActivityNotifications: boolean;
}

View File

@ -119,10 +119,12 @@ export class ActionBarComponent implements OnInit, OnDestroy {
const usableStatus = this.toolsService.getStatusUsableByAccount(account, this.statusWrapper); const usableStatus = this.toolsService.getStatusUsableByAccount(account, this.statusWrapper);
usableStatus usableStatus
.then((status: Status) => { .then((status: Status) => {
if (this.isBoosted) { if (this.isBoosted && status.reblogged) {
return this.mastodonService.unreblog(account, status); return this.mastodonService.unreblog(account, status);
} else { } else if(!this.isBoosted && !status.reblogged){
return this.mastodonService.reblog(account, status); return this.mastodonService.reblog(account, status);
} else {
return Promise.resolve(status);
} }
}) })
.then((boostedStatus: Status) => { .then((boostedStatus: Status) => {
@ -144,10 +146,12 @@ export class ActionBarComponent implements OnInit, OnDestroy {
const usableStatus = this.toolsService.getStatusUsableByAccount(account, this.statusWrapper); const usableStatus = this.toolsService.getStatusUsableByAccount(account, this.statusWrapper);
usableStatus usableStatus
.then((status: Status) => { .then((status: Status) => {
if (this.isFavorited) { if (this.isFavorited && status.favourited) {
return this.mastodonService.unfavorite(account, status); return this.mastodonService.unfavorite(account, status);
} else { } else if(!this.isFavorited && !status.favourited) {
return this.mastodonService.favorite(account, status); return this.mastodonService.favorite(account, status);
} else {
return Promise.resolve(status);
} }
}) })
.then((favoritedStatus: Status) => { .then((favoritedStatus: Status) => {

View File

@ -15,7 +15,7 @@
</a> </a>
<a *ngIf="attachments.length === 3" class="galery__image--link galery__image--link-3-1" <a *ngIf="attachments.length === 3" class="galery__image--link galery__image--link-3-1"
title="{{ attachments[0].text_url }}" (click)="attachmentSelected(0)"> title="{{ attachments[0].description }}" (click)="attachmentSelected(0)">
<img src="{{ attachments[0].preview_url }}" /> <img src="{{ attachments[0].preview_url }}" />
</a> </a>
<a *ngIf="attachments.length === 3" class="galery__image--link galery__image--link-3-2" <a *ngIf="attachments.length === 3" class="galery__image--link galery__image--link-3-2"
@ -28,7 +28,7 @@
</a> </a>
<a *ngIf="attachments.length === 4" class="galery__image--link galery__image--link-4" <a *ngIf="attachments.length === 4" class="galery__image--link galery__image--link-4"
title="{{ attachments[0].text_url }}" (click)="attachmentSelected(0)"> title="{{ attachments[0].description }}" (click)="attachmentSelected(0)">
<img src="{{ attachments[0].preview_url }}" /> <img src="{{ attachments[0].preview_url }}" />
</a> </a>
<a *ngIf="attachments.length === 4" class="galery__image--link galery__image--link-4" <a *ngIf="attachments.length === 4" class="galery__image--link galery__image--link-4"

View File

@ -2,53 +2,75 @@
<a class="reblog__profile-link" href (click)="openAccount(status.account)">{{ status.account.display_name }} <img <a class="reblog__profile-link" href (click)="openAccount(status.account)">{{ status.account.display_name }} <img
*ngIf="reblog" class="reblog__avatar" src="{{ status.account.avatar }}" /></a> boosted *ngIf="reblog" class="reblog__avatar" src="{{ status.account.avatar }}" /></a> boosted
</div> </div>
<div class="status"> <div *ngIf="notificationType === 'favourite'">
<div class="notification--icon">
<a href class="status__profile-link" title="{{displayedStatus.account.acct}}" <fa-icon class="favorite" [icon]="faStar"></fa-icon>
(click)="openAccount(displayedStatus.account)">
<img [class.status__avatar--boosted]="reblog" class="status__avatar"
src="{{ displayedStatus.account.avatar }}" />
<!-- <img *ngIf="reblog" class="status__avatar--reblog" src="{{ status.account.avatar }}" /> -->
<span class="status__name">
<span class="status__name--displayname" innerHTML="{{displayedStatus.account.display_name}}"></span><span
class="status__name--username">{{displayedStatus.account.acct}}</span>
</span>
</a>
<div class="status__created-at" title="{{ displayedStatus.created_at | date: 'full' }}">
<a class="status__created-at--link" href="{{ displayedStatus.url }}" target="_blank">
{{ status.created_at | timeAgo | async }}
</a>
</div> </div>
<div class="status__labels"> <div class="notification--label">
<div class="status__labels--label status__labels--bot" title="bot" *ngIf="status.account.bot"> <a href class="notification--link"
bot (click)="openAccount(notificationAccount)">{{ notificationAccount.display_name }}</a> favorited your status
</div>
<div class="status__labels--label status__labels--xpost" title="this status was cross-posted" *ngIf="isCrossPoster">
x-post
</div>
<div class="status__labels--label status__labels--thread" title="thread" *ngIf="isThread">
thread
</div>
<div class="status__labels--label status__labels--discuss" title="this status has a discution" *ngIf="hasReply">
replies
</div>
</div> </div>
<!-- <div #content class="status__content" innerHTML="{{displayedStatus.content}}"></div> --> </div>
<div *ngIf="notificationType === 'reblog'">
<a href class="status__content-warning" *ngIf="isContentWarned" title="show content" <div class="notification--icon">
(click)="removeContentWarning()"> <fa-icon class="boost" [icon]="faRetweet"></fa-icon>
<span class="status__content-warning--title">sensitive content</span> </div>
{{ contentWarningText }} <div class="notification--label">
</a> <a href class="notification--link" (click)="openAccount(notificationAccount)">{{ notificationAccount.display_name }}</a> boosted your status
<app-databinded-text class="status__content" *ngIf="!isContentWarned" [text]="displayedStatus.content" </div>
(accountSelected)="accountSelected($event)" (hashtagSelected)="hashtagSelected($event)" </div>
(textSelected)="textSelected()"></app-databinded-text> <div class="status">
<app-attachements *ngIf="!isContentWarned && hasAttachments" class="attachments" <div [ngClass]="{'notification--status': notificationAccount }">
[attachments]="displayedStatus.media_attachments"> <a href class="status__profile-link" title="{{displayedStatus.account.acct}}"
</app-attachements> (click)="openAccount(displayedStatus.account)">
<img [class.status__avatar--boosted]="reblog || notificationAccount" class="status__avatar"
<app-action-bar #appActionBar [statusWrapper]="statusWrapper" (cwIsActiveEvent)="changeCw($event)" src="{{ displayedStatus.account.avatar }}" />
(replyEvent)="openReply()"></app-action-bar> <!-- <img *ngIf="reblog" class="status__avatar--reblog" src="{{ status.account.avatar }}" /> -->
<img *ngIf="notificationAccount" class="notification--avatar" src="{{ notificationAccount.avatar }}" />
<app-create-status *ngIf="replyingToStatus" [statusReplyingToWrapper]="statusWrapper" (onClose)="closeReply()"></app-create-status> <span class="status__name">
<span class="status__name--displayname"
innerHTML="{{displayedStatus.account.display_name}}"></span><span
class="status__name--username">{{displayedStatus.account.acct}}</span>
</span>
</a>
<div class="status__created-at" title="{{ displayedStatus.created_at | date: 'full' }}">
<a class="status__created-at--link" href="{{ displayedStatus.url }}" target="_blank">
{{ status.created_at | timeAgo | async }}
</a>
</div>
<div class="status__labels">
<div class="status__labels--label status__labels--bot" title="bot" *ngIf="status.account.bot">
bot
</div>
<div class="status__labels--label status__labels--xpost" title="this status was cross-posted"
*ngIf="isCrossPoster">
x-post
</div>
<div class="status__labels--label status__labels--thread" title="thread" *ngIf="isThread">
thread
</div>
<div class="status__labels--label status__labels--discuss" title="this status has a discution"
*ngIf="hasReply">
replies
</div>
</div>
<!-- <div #content class="status__content" innerHTML="{{displayedStatus.content}}"></div> -->
<a href class="status__content-warning" *ngIf="isContentWarned" title="show content"
(click)="removeContentWarning()">
<span class="status__content-warning--title">sensitive content</span>
{{ contentWarningText }}
</a>
<app-databinded-text class="status__content" *ngIf="!isContentWarned" [text]="displayedStatus.content"
(accountSelected)="accountSelected($event)" (hashtagSelected)="hashtagSelected($event)"
(textSelected)="textSelected()"></app-databinded-text>
<app-attachements *ngIf="!isContentWarned && hasAttachments" class="attachments"
[attachments]="displayedStatus.media_attachments">
</app-attachements>
<app-action-bar #appActionBar [statusWrapper]="statusWrapper" (cwIsActiveEvent)="changeCw($event)"
(replyEvent)="openReply()"></app-action-bar>
</div>
<app-create-status *ngIf="replyingToStatus" [statusReplyingToWrapper]="statusWrapper"
(onClose)="closeReply()"></app-create-status>
</div> </div>

View File

@ -154,3 +154,52 @@
display: block; // width: calc(100% - 80px); display: block; // width: calc(100% - 80px);
margin: 10px 10px 0 $avatar-column-space; margin: 10px 10px 0 $avatar-column-space;
} }
.notification {
position: relative;
&--icon {
position: absolute;
top: 5px;
left: 43px;
text-align: center;
width: 20px;
// outline: 1px dotted greenyellow;
}
&--label {
margin: 0 10px 0 $avatar-column-space;
padding-top: 5px;
}
&--link {
color: $status-links-color;
}
&--status:not(.reply-section) {
opacity: 0.65;
}
&--avatar {
position: absolute;
top: 35px;
left: 30px;
width: 30px;
height: 30px;
border-radius: 2px;
z-index: 10;
}
// &:not(:last-child) {
// border: solid #06070b;
// border-width: 0 0 1px 0;
// }
}
.boost {
color: $boost-color;
}
.favorite {
color: $favorite-color;
}

View File

@ -1,4 +1,6 @@
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 { Status, Account } from "../../../services/models/mastodon.interfaces"; import { Status, Account } from "../../../services/models/mastodon.interfaces";
import { OpenThreadEvent } from "../../../services/tools.service"; import { OpenThreadEvent } from "../../../services/tools.service";
import { ActionBarComponent } from "./action-bar/action-bar.component"; import { ActionBarComponent } from "./action-bar/action-bar.component";
@ -10,6 +12,9 @@ import { StatusWrapper } from '../../../models/common.model';
styleUrls: ["./status.component.scss"] styleUrls: ["./status.component.scss"]
}) })
export class StatusComponent implements OnInit { export class StatusComponent implements OnInit {
faStar = faStar;
faRetweet = faRetweet;
displayedStatus: Status; displayedStatus: Status;
reblog: boolean; reblog: boolean;
hasAttachments: boolean; hasAttachments: boolean;
@ -27,6 +32,9 @@ export class StatusComponent implements OnInit {
@Input() isThreadDisplay: boolean; @Input() isThreadDisplay: boolean;
@Input() notificationType: 'mention' | 'reblog' | 'favourite';
@Input() notificationAccount: Account;
private _statusWrapper: StatusWrapper; private _statusWrapper: StatusWrapper;
status: Status; status: Status;
@Input('statusWrapper') @Input('statusWrapper')
@ -92,7 +100,7 @@ export class StatusComponent implements OnInit {
} }
} }
if(this.isThreadDisplay) return; if (this.isThreadDisplay) return;
if (status.in_reply_to_account_id && status.in_reply_to_account_id === status.account.id) { if (status.in_reply_to_account_id && status.in_reply_to_account_id === status.account.id) {
this.isThread = true; this.isThread = true;

View File

@ -1,4 +1,8 @@
<div class="stream-toots flexcroll" #statusstream (scroll)="onScroll()"> <div class="stream-toots flexcroll" #statusstream (scroll)="onScroll()">
<div class="stream-toots__remove-cw" *ngIf="isThread && hasContentWarnings">
<button class="stream-toots__remove-cw--button" (click)="removeCw()"
title="remove content warnings">Remove CWs</button>
</div>
<div *ngIf="displayError" class="stream-toots__error">{{displayError}}</div> <div *ngIf="displayError" class="stream-toots__error">{{displayError}}</div>
<!-- data-simplebar --> <!-- data-simplebar -->

View File

@ -1,19 +1,49 @@
@import "variables"; @import "variables";
@import "commons"; @import "commons";
@import "mixins";
.stream-toots { .stream-toots {
height: calc(100%); height: calc(100%);
width: calc(100%); width: calc(100%);
overflow: auto; overflow: auto;
&__error { &__error {
padding: 20px 20px 0 20px; padding: 20px 20px 0 20px;
color: rgb(255, 113, 113); color: rgb(255, 113, 113);
} }
&__status:not(:last-child) { &__status:not(:last-child) {
border: solid #06070b; border: solid #06070b;
border-width: 0 0 1px 0; border-width: 0 0 1px 0;
} }
&__remove-cw {
padding: 5px;
// border: solid #06070b;
// border-width: 0 0 1px 0;
height: 45px;
// width: calc(100%);
// position: relative;
&--button {
@include clearButton;
// position: absolute;
// width: calc(80%);
// margin-left: 40%;
// transform: translateX(-40%);
width: calc(100%);
padding: 5px 0;
z-index: 10;
text-align: center;
border: 3px $status-secondary-color double;
transition: all .2s;
background-color: $color-secondary;
&:hover{
$hover-color: $status-secondary-color;
background-color: $hover-color;
color: white;
border: 3px $hover-color double;
}
}
}
} }

View File

@ -21,6 +21,7 @@ export class StreamStatusesComponent implements OnInit, OnDestroy {
isLoading = true; isLoading = true;
isThread = false; isThread = false;
displayError: string; displayError: string;
hasContentWarnings = false;
private _streamElement: StreamElement; private _streamElement: StreamElement;
private account: AccountInfo; private account: AccountInfo;

View File

@ -1,4 +1,4 @@
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; import { Component, OnInit, Input, Output, EventEmitter, ViewChildren, QueryList } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { MastodonService } from '../../../services/mastodon.service'; import { MastodonService } from '../../../services/mastodon.service';
@ -7,6 +7,7 @@ import { Results, Context, Status } from '../../../services/models/mastodon.inte
import { NotificationService } from '../../../services/notification.service'; import { NotificationService } from '../../../services/notification.service';
import { AccountInfo } from '../../../states/accounts.state'; import { AccountInfo } from '../../../states/accounts.state';
import { StatusWrapper } from '../../../models/common.model'; import { StatusWrapper } from '../../../models/common.model';
import { StatusComponent } from '../status/status.component';
@Component({ @Component({
selector: 'app-thread', selector: 'app-thread',
@ -18,6 +19,7 @@ export class ThreadComponent implements OnInit {
displayError: string; displayError: string;
isLoading = true; isLoading = true;
isThread = true; isThread = true;
hasContentWarnings = false;
private lastThreadEvent: OpenThreadEvent; private lastThreadEvent: OpenThreadEvent;
@ -33,6 +35,8 @@ export class ThreadComponent implements OnInit {
} }
} }
@ViewChildren(StatusComponent) statusChildren: QueryList<StatusComponent>;
constructor( constructor(
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService,
private readonly toolsService: ToolsService, private readonly toolsService: ToolsService,
@ -86,6 +90,8 @@ export class ThreadComponent implements OnInit {
const wrapper = new StatusWrapper(s, currentAccount); const wrapper = new StatusWrapper(s, currentAccount);
this.statuses.push(wrapper); this.statuses.push(wrapper);
} }
this.hasContentWarnings = this.statuses.filter(x => x.status.sensitive || x.status.spoiler_text).length > 1;
}); });
}) })
@ -119,4 +125,12 @@ export class ThreadComponent implements OnInit {
browseThread(openThreadEvent: OpenThreadEvent): void { browseThread(openThreadEvent: OpenThreadEvent): void {
this.browseThreadEvent.next(openThreadEvent); this.browseThreadEvent.next(openThreadEvent);
} }
removeCw(){
const statuses = this.statusChildren.toArray();
statuses.forEach(x => {
x.removeContentWarning();
});
this.hasContentWarnings = false;
}
} }

View File

@ -90,9 +90,6 @@ export class UserProfileComponent implements OnInit {
return this.toolsService.findAccount(this.currentlyUsedAccount, this.lastAccountName) return this.toolsService.findAccount(this.currentlyUsedAccount, this.lastAccountName)
.then((account: Account) => { .then((account: Account) => {
console.warn(account);
this.isLoading = false; this.isLoading = false;
this.statusLoading = true; this.statusLoading = true;

View File

@ -1,8 +1,8 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpHeaders, HttpClient } 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 } from "./models/mastodon.interfaces"; import { Account, Status, Results, Context, Relationship, Instance, Attachment, Notification } from "./models/mastodon.interfaces";
import { AccountInfo } from '../states/accounts.state'; import { AccountInfo } from '../states/accounts.state';
import { StreamTypeEnum } from '../states/streams.state'; import { StreamTypeEnum } from '../states/streams.state';
@ -134,6 +134,27 @@ export class MastodonService {
return this.httpClient.get<Context>(route, { headers: headers }).toPromise(); return this.httpClient.get<Context>(route, { headers: headers }).toPromise();
} }
getFavorites(account: AccountInfo, maxId: string = null): Promise<FavoriteResult> { //, minId: string = null
let route = `https://${account.instance}${this.apiRoutes.getFavourites}`; //?limit=${limit}
if (maxId) route += `?max_id=${maxId}`;
//if (minId) route += `&min_id=${minId}`;
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.get(route, { headers: headers, observe: "response" }).toPromise()
.then((res: HttpResponse<Status[]>) => {
const link = res.headers.get('Link');
let lastId = null;
if(link){
const maxId = link.split('max_id=')[1];
if(maxId){
lastId = maxId.split('>;')[0];
}
}
return new FavoriteResult(lastId, res.body);
});
}
searchAccount(account: AccountInfo, query: string, limit: number = 40, following: boolean = false): Promise<Account[]> { 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}`; const route = `https://${account.instance}${this.apiRoutes.searchForAccounts}?q=${query}&limit=${limit}&following=${following}`;
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` }); const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
@ -152,20 +173,18 @@ export class MastodonService {
return this.httpClient.post<Status>(route, null, { headers: headers }).toPromise() return this.httpClient.post<Status>(route, null, { headers: headers }).toPromise()
} }
favorite(account: AccountInfo, status: Status): any { favorite(account: AccountInfo, status: Status): Promise<Status> {
const route = `https://${account.instance}${this.apiRoutes.favouritingStatus}`.replace('{0}', status.id); const route = `https://${account.instance}${this.apiRoutes.favouritingStatus}`.replace('{0}', status.id);
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` }); const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.post<Status>(route, null, { headers: headers }).toPromise() return this.httpClient.post<Status>(route, null, { headers: headers }).toPromise()
} }
unfavorite(account: AccountInfo, status: Status): any { unfavorite(account: AccountInfo, status: Status): Promise<Status> {
const route = `https://${account.instance}${this.apiRoutes.unfavouritingStatus}`.replace('{0}', status.id); const route = `https://${account.instance}${this.apiRoutes.unfavouritingStatus}`.replace('{0}', status.id);
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` }); const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.post<Status>(route, null, { headers: headers }).toPromise() return this.httpClient.post<Status>(route, null, { headers: headers }).toPromise()
} }
getRelationships(account: AccountInfo, accountsToRetrieve: Account[]): Promise<Relationship[]> { getRelationships(account: AccountInfo, accountsToRetrieve: Account[]): Promise<Relationship[]> {
let params = `?${this.formatArray(accountsToRetrieve.map(x => x.id.toString()), 'id')}`; let params = `?${this.formatArray(accountsToRetrieve.map(x => x.id.toString()), 'id')}`;
@ -202,7 +221,7 @@ export class MastodonService {
} }
//TODO: add focus support //TODO: add focus support
updateMediaAttachment(account: AccountInfo, mediaId: string, description: string): Promise<Attachment> { updateMediaAttachment(account: AccountInfo, mediaId: string, description: string): Promise<Attachment> {
let input = new FormData(); let input = new FormData();
input.append('description', description); input.append('description', description);
const route = `https://${account.instance}${this.apiRoutes.updateMediaAttachment.replace('{0}', mediaId)}`; const route = `https://${account.instance}${this.apiRoutes.updateMediaAttachment.replace('{0}', mediaId)}`;
@ -210,10 +229,30 @@ export class MastodonService {
return this.httpClient.put<Attachment>(route, input, { headers: headers }).toPromise(); return this.httpClient.put<Attachment>(route, input, { headers: headers }).toPromise();
} }
getNotifications(account: AccountInfo, excludeTypes: string[] = null, maxId: string = null, sinceId: string = null, limit: number = 15): Promise<Notification[]> {
let route = `https://${account.instance}${this.apiRoutes.getNotifications}?limit=${limit}`;
if(maxId){
route += `&max_id=${maxId}`;
}
if(sinceId){
route += `&since_id=${sinceId}`;
}
if(excludeTypes && excludeTypes.length > 0) {
const excludeTypeArray = this.formatArray(excludeTypes, 'exclude_types');
route += `&${excludeTypeArray}`;
}
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.get<Notification[]>(route, { headers: headers }).toPromise();
}
private formatArray(data: string[], paramName: string): string { private formatArray(data: string[], paramName: string): string {
let result = ''; let result = '';
data.forEach(x => { data.forEach(x => {
if (result.includes('paramName')) result += '&'; if (result.includes(paramName)) result += '&';
result += `${paramName}[]=${x}`; result += `${paramName}[]=${x}`;
}); });
return result; return result;
@ -236,3 +275,9 @@ class StatusData {
spoiler_text: string; spoiler_text: string;
visibility: string; visibility: string;
} }
export class FavoriteResult {
constructor(
public max_id: string,
public favorites: Status[]) {}
}

View File

@ -11,7 +11,12 @@ export interface TokenData {
access_token: string; access_token: string;
token_type: string; token_type: string;
scope: string; scope: string;
created_at: string; created_at: number;
//TODO: Pleroma support this
me: string;
expires_in: number;
refresh_token: string;
} }
export interface Account { export interface Account {

View File

@ -5,6 +5,7 @@ import { AccountInfo } from '../states/accounts.state';
import { MastodonService } from './mastodon.service'; import { MastodonService } from './mastodon.service';
import { Account, Results, Status } from "./models/mastodon.interfaces"; import { Account, Results, Status } from "./models/mastodon.interfaces";
import { StatusWrapper } from '../models/common.model'; import { StatusWrapper } from '../models/common.model';
import { AccountSettings, SaveAccountSettings } from '../states/settings.state';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -21,6 +22,23 @@ export class ToolsService {
return regAccounts.filter(x => x.isSelected); return regAccounts.filter(x => x.isSelected);
} }
getAccountSettings(account: AccountInfo): AccountSettings {
var accountsSettings = <AccountSettings[]>this.store.snapshot().globalsettings.settings.accountSettings;
let accountSettings = accountsSettings.find(x => x.accountId === account.id);
if(!accountSettings){
accountSettings = new AccountSettings();
accountSettings.accountId = account.id;
this.saveAccountSettings(accountSettings);
}
return accountSettings;
}
saveAccountSettings(accountSettings: AccountSettings){
this.store.dispatch([
new SaveAccountSettings(accountSettings)
])
}
findAccount(account: AccountInfo, accountName: string): Promise<Account> { findAccount(account: AccountInfo, accountName: string): Promise<Account> {
return this.mastodonService.search(account, accountName, true) return this.mastodonService.search(account, accountName, true)
.then((result: Results) => { .then((result: Results) => {
@ -42,7 +60,7 @@ export class ToolsService {
if (!isProvider) { if (!isProvider) {
statusPromise = statusPromise.then((foreignStatus: Status) => { statusPromise = statusPromise.then((foreignStatus: Status) => {
const statusUrl = foreignStatus.url; const statusUrl = foreignStatus.url;
return this.mastodonService.search(account, statusUrl) return this.mastodonService.search(account, statusUrl, true)
.then((results: Results) => { .then((results: Results) => {
return results.statuses[0]; return results.statuses[0];
}); });
@ -51,6 +69,7 @@ export class ToolsService {
return statusPromise; return statusPromise;
} }
} }
export class OpenThreadEvent { export class OpenThreadEvent {

View File

@ -0,0 +1,12 @@
import { TestBed } from '@angular/core/testing';
import { UserNotificationService } from './user-notification.service';
xdescribe('UserNotificationServiceService', () => {
beforeEach(() => TestBed.configureTestingModule({}));
it('should be created', () => {
const service: UserNotificationService = TestBed.get(UserNotificationService);
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,202 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Subject, Observable, Subscription } from 'rxjs';
import { Store } from '@ngxs/store';
import { Status, Notification } from './models/mastodon.interfaces';
import { MastodonService } from './mastodon.service';
import { AccountInfo } from '../states/accounts.state';
import { NotificationService } from './notification.service';
import { ToolsService } from './tools.service';
@Injectable({
providedIn: 'root'
})
export class UserNotificationService {
userNotifications = new BehaviorSubject<UserNotification[]>([]);
private sinceIds: { [id: string]: string } = {};
constructor(
private readonly toolsService: ToolsService,
private readonly notificationService: NotificationService,
private readonly mastodonService: MastodonService,
private readonly store: Store) {
this.fetchNotifications();
}
private fetchNotifications() {
let accounts = this.store.snapshot().registeredaccounts.accounts;
let promises: Promise<any>[] = [];
accounts.forEach((account: AccountInfo) => {
let sinceId = null;
if (this.sinceIds[account.id]) {
sinceId = this.sinceIds[account.id];
}
let getNotificationPromise = this.mastodonService.getNotifications(account, null, null, sinceId, 30)
.then((notifications: Notification[]) => {
this.processNotifications(account, notifications);
})
.catch(err => {
this.notificationService.notifyHttpError(err);
});
promises.push(getNotificationPromise);
});
Promise.all(promises)
.then(() => {
setTimeout(() => {
this.fetchNotifications();
}, 15 * 1000);
});
}
private processNotifications(account: AccountInfo, notifications: Notification[]) {
if (notifications.length === 0) {
return;
}
let currentNotifications = this.userNotifications.value;
let currentAccountNotifications = currentNotifications.find(x => x.account.id === account.id);
const sinceId = notifications[0].id;
this.sinceIds[account.id] = sinceId;
if (currentAccountNotifications) {
currentAccountNotifications.allNotifications = [...notifications, ...currentAccountNotifications.allNotifications];
currentAccountNotifications = this.analyseNotifications(account, currentAccountNotifications);
if (currentAccountNotifications.hasNewMentions || currentAccountNotifications.hasNewNotifications) {
currentNotifications = currentNotifications.filter(x => x.account.id !== account.id);
currentNotifications.push(currentAccountNotifications);
this.userNotifications.next(currentNotifications);
}
} else {
let newNotifications = new UserNotification();
newNotifications.account = account;
newNotifications.allNotifications = notifications;
newNotifications = this.analyseNotifications(account, newNotifications);
currentNotifications.push(newNotifications);
this.userNotifications.next(currentNotifications);
}
}
private analyseNotifications(account: AccountInfo, userNotification: UserNotification): UserNotification {
if (userNotification.allNotifications.length > 30) {
userNotification.allNotifications.length = 30;
}
userNotification.lastId = userNotification.allNotifications[userNotification.allNotifications.length - 1].id;
const newNotifications = userNotification.allNotifications.filter(x => x.type !== 'mention');
const newMentions = userNotification.allNotifications.filter(x => x.type === 'mention').map(x => x.status);
const currentNotifications = userNotification.notifications;
const currentMentions = userNotification.mentions;
userNotification.notifications = [...newNotifications, ...currentNotifications];
userNotification.mentions = [...newMentions, ...currentMentions];
const accountSettings = this.toolsService.getAccountSettings(account);
if(accountSettings.lastMentionReadId && userNotification.mentions[0] && accountSettings.lastMentionReadId !== userNotification.mentions[0].id){
userNotification.hasNewMentions = true;
} else {
userNotification.hasNewMentions = false;
}
if(accountSettings.lastNotificationReadId && userNotification.notifications[0] && accountSettings.lastNotificationReadId !== userNotification.notifications[0].id){
userNotification.hasNewNotifications = true;
} else {
userNotification.hasNewNotifications = false;
}
if((!accountSettings.lastMentionReadId && userNotification.mentions[0])
|| (!accountSettings.lastNotificationReadId && userNotification.notifications[0])){
accountSettings.lastMentionReadId = userNotification.mentions[0].id;
accountSettings.lastNotificationReadId = userNotification.notifications[0].id;
this.toolsService.saveAccountSettings(accountSettings);
}
// if (!currentNotifications) {
// userNotification.notifications = newNotifications;
// } else if (currentNotifications.length === 0) {
// if (newNotifications.length > 0) {
// userNotification.hasNewNotifications = true;
// }
// userNotification.notifications = newNotifications;
// } else if (newNotifications.length > 0) {
// userNotification.hasNewNotifications = currentNotifications[0].id !== newNotifications[0].id;
// userNotification.notifications = [...newNotifications, ...currentNotifications];
// }
// if (!currentNotifications) {
// userNotification.mentions = newMentions;
// } else if (currentMentions.length === 0) {
// if (newMentions.length > 0) {
// userNotification.hasNewMentions = true;
// }
// userNotification.mentions = newMentions;
// } else if (newMentions.length > 0) {
// userNotification.hasNewMentions = currentMentions[0].id !== newMentions[0].id;
// userNotification.mentions = [...newMentions, ...currentMentions];
// }
return userNotification;
}
markMentionsAsRead(account: AccountInfo) {
let currentNotifications = this.userNotifications.value;
const currentAccountNotifications = currentNotifications.find(x => x.account.id === account.id);
const lastMention = currentAccountNotifications.mentions[0];
if(lastMention){
// const lastNotification = currentAccountNotifications.allNotifications.find(x => x.status && x.status.id === lastMention.id);
const settings = this.toolsService.getAccountSettings(account);
settings.lastMentionReadId = lastMention.id;
this.toolsService.saveAccountSettings(settings);
}
if (currentAccountNotifications.hasNewMentions === true) {
currentAccountNotifications.hasNewMentions = false;
this.userNotifications.next(currentNotifications);
}
}
markNotificationAsRead(account: AccountInfo) {
let currentNotifications = this.userNotifications.value;
const currentAccountNotifications = currentNotifications.find(x => x.account.id === account.id);
const lastNotification = currentAccountNotifications.notifications[0];
if(lastNotification){
const settings = this.toolsService.getAccountSettings(account);
settings.lastNotificationReadId = lastNotification.id;
this.toolsService.saveAccountSettings(settings);
}
if (currentAccountNotifications.hasNewNotifications === true) {
currentAccountNotifications.hasNewNotifications = false;
this.userNotifications.next(currentNotifications);
}
}
}
export class UserNotification {
account: AccountInfo;
allNotifications: Notification[] = [];
hasNewNotifications: boolean;
hasNewMentions: boolean;
notifications: Notification[] = [];
mentions: Status[] = [];
lastId: string;
}

View File

@ -0,0 +1,88 @@
import { State, Action, StateContext, Selector, createSelector } from '@ngxs/store';
export class RemoveAccountSettings {
static readonly type = '[Settings] Remove AccountSettings';
constructor(public accountId: string) {}
}
export class SaveAccountSettings {
static readonly type = '[Settings] Save AccountSettings';
constructor(public accountSettings: AccountSettings) {}
}
export class SaveSettings {
static readonly type = '[Settings] Save Settings';
constructor(public settings: GlobalSettings) {}
}
export class AccountSettings {
accountId: string;
displayMention: boolean = true;
displayNotifications: boolean = true;
lastMentionReadId: string;
lastNotificationReadId: string;
}
export class GlobalSettings {
disableAllNotifications = false;
accountSettings: AccountSettings[] = [];
}
export interface SettingsStateModel {
settings: GlobalSettings;
}
@State<SettingsStateModel>({
name: 'globalsettings',
defaults: {
settings: new GlobalSettings()
}
})
export class SettingsState {
accountSettings(accountId: string){
return createSelector([SettingsState], (state: GlobalSettings) => {
return state.accountSettings.find(x => x.accountId === accountId);
});
}
@Action(RemoveAccountSettings)
RemoveAccountSettings(ctx: StateContext<SettingsStateModel>, action: RemoveAccountSettings){
const state = ctx.getState();
const newSettings = new GlobalSettings();
newSettings.disableAllNotifications = state.settings.disableAllNotifications;
newSettings.accountSettings = [...state.settings.accountSettings.filter(x => x.accountId !== action.accountId)];
ctx.patchState({
settings: newSettings
});
}
@Action(SaveAccountSettings)
SaveAccountSettings(ctx: StateContext<SettingsStateModel>, action: SaveAccountSettings){
const state = ctx.getState();
const newSettings = new GlobalSettings();
newSettings.disableAllNotifications = state.settings.disableAllNotifications;
newSettings.accountSettings = [...state.settings.accountSettings.filter(x => x.accountId !== action.accountSettings.accountId), action.accountSettings];
ctx.patchState({
settings: newSettings
});
}
@Action(SaveSettings)
SaveSettings(ctx: StateContext<SettingsStateModel>, action: SaveSettings){
const state = ctx.getState();
const newSettings = new GlobalSettings();
newSettings.disableAllNotifications = action.settings.disableAllNotifications;
newSettings.accountSettings = [...state.settings.accountSettings];
ctx.patchState({
settings: newSettings
});
}
}

View File

@ -3,9 +3,8 @@
width: calc(100%); width: calc(100%);
height: calc(100%); height: calc(100%);
padding: 10px 10px 0 7px; padding: 10px 10px 0 7px;
font-size: $small-font-size; font-size: $small-font-size; //FIXME: remove this
white-space: normal; white-space: normal;
// overflow: auto;
&__title { &__title {
font-size: 13px; font-size: 13px;
text-transform: uppercase; text-transform: uppercase;

View File

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