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",
"version": "0.0.0",
"license": "MIT",
"main": "main.js",
"license": "AGPL-3.0-or-later",
"main": "main-electron.js",
"scripts": {
"ng": "ng",
"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 { CreateStatusComponent } from './components/create-status/create-status.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 = [
{ path: "", redirectTo: "home", pathMatch: "full" },
@ -89,7 +95,12 @@ const routes: Routes = [
NotificationHubComponent,
MediaViewerComponent,
CreateStatusComponent,
MediaComponent
MediaComponent,
MyAccountComponent,
FavoritesComponent,
DirectMessagesComponent,
MentionsComponent,
NotificationsComponent
],
imports: [
FontAwesomeModule,
@ -102,7 +113,8 @@ const routes: Routes = [
NgxsModule.forRoot([
RegisteredAppsState,
AccountsState,
StreamsState
StreamsState,
SettingsState
]),
NgxsStoragePluginModule.forRoot()
],

View File

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

View File

@ -10,7 +10,11 @@
</a>
</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-account *ngIf="openPanel === 'addNewAccount'"></app-add-new-account>
<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">
<h3 class="panel__title">Manage Account</h3>
<div class="account-editor__display-avatar">
<img class="account-editor__avatar" src="{{account.avatar}}" title="{{ account.info.id }} " />
</div>
<div class="account__header">
<img class="account__avatar" src="{{account.avatar}}" title="{{ account.info.id }} " />
<h4 class="account__label">add column:</h4>
<a class="account__link account__blue" href *ngFor="let stream of availableStreams" (click)="addStream(stream)">
{{ stream.name }}
<!-- <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 class="add-column__link" href>
Global Timeline
<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 class="add-column__link" href>
Personnal Timeline
<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 class="add-column__link" href>
Lists, Favs, Activitires, etc
</a> -->
<h4 class="account__label account__margin-top">remove account from sengi:</h4>
<a class="account__link account__red" href (click)="removeAccount()">
Delete
<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>
<app-direct-messages class="account__body" *ngIf="subPanel === 'dm'"
[account]="account"
(browseAccountEvent)="browseAccount($event)"
(browseHashtagEvent)="browseHashtag($event)"
(browseThreadEvent)="browseThread($event)"></app-direct-messages>
<app-favorites class="account__body" *ngIf="subPanel === 'favorites'"
[account]="account"
(browseAccountEvent)="browseAccount($event)"
(browseHashtagEvent)="browseHashtag($event)"
(browseThreadEvent)="browseThread($event)"></app-favorites>
<app-mentions class="account__body" *ngIf="subPanel === 'mentions'"
[account]="account"
(browseAccountEvent)="browseAccount($event)"
(browseHashtagEvent)="browseHashtag($event)"
(browseThreadEvent)="browseThread($event)"></app-mentions>
<app-my-account class="account__body" *ngIf="subPanel === 'account'"
[account]="account"></app-my-account>
<app-notifications class="account__body" *ngIf="subPanel === 'notifications'"
[account]="account"
(browseAccountEvent)="browseAccount($event)"
(browseHashtagEvent)="browseHashtag($event)"
(browseThreadEvent)="browseThread($event)"></app-notifications>
</div>

View File

@ -1,67 +1,60 @@
@import "variables";
@import "panel";
.account-editor {
// padding: 10px 10px 0 7px;
// font-size: $small-font-size;
// &__title {
// font-size: 13px;
// 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;
}
@import "commons";
$account-header-height: 60px;
.panel {
padding-left: 0px;
padding-right: 0px;
}
.account {
&__label {
// text-decoration: underline;
font-size: $small-font-size;
&__header {
// padding-left: 10px;
padding-left: 5px;
padding-right: 10px;
padding-bottom: 5px;
height: $account-header-height; //border-top: 1px solid #222736;
border-bottom: 1px solid #222736;
&--button {
// outline: 1px greenyellow solid;
margin-top: 20px;
width: 35px;
height: 35px;
float: right;
margin-left: 5px;
color: $font-color-secondary;
}
&__margin-top {
margin-top: 25px;
}
&__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;
}
}
&__mid-link {
text-decoration: none;
display: block; // width: calc(100% - 20px);
width: 45%; // height: 30px;
padding: 5px 10px; // border: solid 1px black;
&:not(:last-child) {
margin-bottom: 5px;
}
}
&__blue {
background-color: $color-primary;
color: #fff;
font-size: 22px;
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 {
background-color: lighten($color-primary, 15);
color: $font-link-primary-hover;
}
}
&__red {
$red-button-color: rgb(65, 3, 3);
background-color: $red-button-color;
color: #fff;
&--selected {
color: whitesmoke;
&:hover {
background-color: lighten($red-button-color, 15);
color: whitesmoke;
}
}
&--notification {
color: rgb(250, 152, 41);
&:hover {
color: rgb(255, 185, 106);
}
}
}
}
&__avatar {
width: 50px;
border-radius: 3px;
}
&__body {
overflow: auto;
height: calc(100% - #{$account-header-height} - 31px);
display: block;
font-size: $default-font-size;
}
}

View File

@ -1,48 +1,85 @@
import { Component, OnInit, Input } from '@angular/core';
import { StreamElement, StreamTypeEnum, AddStream, RemoveAllStreams } from '../../../states/streams.state';
import { Store } from '@ngxs/store';
import { AccountsStateModel, AccountInfo, RemoveAccount } from '../../../states/accounts.state';
import { Component, OnInit, OnDestroy, Input, Output, EventEmitter } from '@angular/core';
import { faAt, faUserPlus } from "@fortawesome/free-solid-svg-icons";
import { faBell, faEnvelope, faUser, faStar } from "@fortawesome/free-regular-svg-icons";
import { Subscription } from 'rxjs';
import { AccountWrapper } from '../../../models/account.models';
import { NavigationService } from '../../../services/navigation.service';
import { NotificationService } from '../../../services/notification.service';
import { UserNotificationService, UserNotification } from '../../../services/user-notification.service';
import { OpenThreadEvent } from '../../../services/tools.service';
@Component({
selector: 'app-manage-account',
templateUrl: './manage-account.component.html',
styleUrls: ['./manage-account.component.scss']
})
export class ManageAccountComponent implements OnInit {
@Input() account: AccountWrapper;
export class ManageAccountComponent implements OnInit, OnDestroy {
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(
private readonly store: Store,
private readonly navigationService: NavigationService,
private notificationService: NotificationService) { }
private readonly userNotificationService: UserNotificationService) { }
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);
ngOnDestroy(): void {
this.userNotificationServiceSub.unsubscribe();
}
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;
}
removeAccount(): boolean {
const accountId = this.account.info.id;
this.store.dispatch([new RemoveAllStreams(accountId), new RemoveAccount(accountId)]);
this.navigationService.closePanel();
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);
}
}

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"
href title="{{ account.info.id }}" (click)="toogleAccount()" (contextmenu)="openMenu()">
<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>

View File

@ -1,45 +1,98 @@
.account-icon {
display: inline-block;
width: 50px;
// padding-top: 4px;
width: 50px; // padding-top: 4px;
// margin-left: 5px;
margin: 0 0 5px 5px;
&__avatar {
border-radius: 50%;
border-radius: 2px;
width: 40px;
opacity: .3;
transition: all .2s;
&:hover {
filter: alpha(opacity=50);
opacity: .5;
}
&--selected {
// border-radius: 20%;
filter: alpha(opacity=100);
opacity: 1;
&:hover {
filter: alpha(opacity=100);
opacity: 1;
}
}
}
}
// & a {
// margin-left: 4px;
// /*margin-top: 4px;*/
// }
// & img {
// width: 40px;
// border-radius: 50%;
// }
@keyframes flickerAnimation {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
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,5 +1,7 @@
import { Component, OnInit, Input, EventEmitter, Output } from '@angular/core';
import { AccountWrapper } from '../../../models/account.models';
import { AccountWithNotificationWrapper } from '../left-side-bar.component';
@Component({
selector: 'app-account-icon',
@ -7,7 +9,7 @@ import { AccountWrapper } from '../../../models/account.models';
styleUrls: ['./account-icon.component.scss']
})
export class AccountIconComponent implements OnInit {
@Input() account: AccountWrapper;
@Input() account: AccountWithNotificationWrapper;
@Output() toogleAccountNotify = new EventEmitter<AccountWrapper>();
@Output() openMenuNotify = new EventEmitter<AccountWrapper>();

View File

@ -10,6 +10,7 @@ import { AccountInfo, SelectAccount } from "../../states/accounts.state";
import { NavigationService, LeftPanelType } from "../../services/navigation.service";
import { MastodonService } from "../../services/mastodon.service";
import { NotificationService } from "../../services/notification.service";
import { UserNotificationService, UserNotification } from '../../services/user-notification.service';
@Component({
selector: "app-left-side-bar",
@ -19,14 +20,15 @@ import { NotificationService } from "../../services/notification.service";
export class LeftSideBarComponent implements OnInit, OnDestroy {
faCommentAlt = faCommentAlt;
accounts: AccountWrapper[] = [];
accounts: AccountWithNotificationWrapper[] = [];
hasAccounts: boolean;
private accounts$: Observable<AccountInfo[]>;
// private loadedAccounts: { [index: string]: AccountInfo } = {};
private sub: Subscription;
private accountSub: Subscription;
private notificationSub: Subscription;
constructor(
private readonly userNotificationServiceService: UserNotificationService,
private readonly notificationService: NotificationService,
private readonly navigationService: NavigationService,
private readonly mastodonService: MastodonService,
@ -37,7 +39,7 @@ export class LeftSideBarComponent implements OnInit, OnDestroy {
private currentLoading: number;
ngOnInit() {
this.accounts$.subscribe((accounts: AccountInfo[]) => {
this.accountSub = this.accounts$.subscribe((accounts: AccountInfo[]) => {
if (accounts) {
//Update and Add
for (let acc of accounts) {
@ -45,8 +47,9 @@ export class LeftSideBarComponent implements OnInit, OnDestroy {
if (previousAcc) {
previousAcc.info.isSelected = acc.isSelected;
} else {
const accWrapper = new AccountWrapper();
const accWrapper = new AccountWithNotificationWrapper();
accWrapper.info = acc;
this.accounts.push(accWrapper);
this.mastodonService.retrieveAccountDetails(acc)
@ -68,10 +71,24 @@ export class LeftSideBarComponent implements OnInit, OnDestroy {
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 {
this.sub.unsubscribe();
this.accountSub.unsubscribe();
this.notificationSub.unsubscribe();
}
onToogleAccountNotify(acc: AccountWrapper) {
@ -102,3 +119,14 @@ export class LeftSideBarComponent implements OnInit, OnDestroy {
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);
usableStatus
.then((status: Status) => {
if (this.isBoosted) {
if (this.isBoosted && status.reblogged) {
return this.mastodonService.unreblog(account, status);
} else {
} else if(!this.isBoosted && !status.reblogged){
return this.mastodonService.reblog(account, status);
} else {
return Promise.resolve(status);
}
})
.then((boostedStatus: Status) => {
@ -144,10 +146,12 @@ export class ActionBarComponent implements OnInit, OnDestroy {
const usableStatus = this.toolsService.getStatusUsableByAccount(account, this.statusWrapper);
usableStatus
.then((status: Status) => {
if (this.isFavorited) {
if (this.isFavorited && status.favourited) {
return this.mastodonService.unfavorite(account, status);
} else {
} else if(!this.isFavorited && !status.favourited) {
return this.mastodonService.favorite(account, status);
} else {
return Promise.resolve(status);
}
})
.then((favoritedStatus: Status) => {

View File

@ -15,7 +15,7 @@
</a>
<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 }}" />
</a>
<a *ngIf="attachments.length === 3" class="galery__image--link galery__image--link-3-2"
@ -28,7 +28,7 @@
</a>
<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 }}" />
</a>
<a *ngIf="attachments.length === 4" class="galery__image--link galery__image--link-4"

View File

@ -2,15 +2,34 @@
<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
</div>
<div *ngIf="notificationType === 'favourite'">
<div class="notification--icon">
<fa-icon class="favorite" [icon]="faStar"></fa-icon>
</div>
<div class="notification--label">
<a href class="notification--link"
(click)="openAccount(notificationAccount)">{{ notificationAccount.display_name }}</a> favorited your status
</div>
</div>
<div *ngIf="notificationType === 'reblog'">
<div class="notification--icon">
<fa-icon class="boost" [icon]="faRetweet"></fa-icon>
</div>
<div class="notification--label">
<a href class="notification--link" (click)="openAccount(notificationAccount)">{{ notificationAccount.display_name }}</a> boosted your status
</div>
</div>
<div class="status">
<div [ngClass]="{'notification--status': notificationAccount }">
<a href class="status__profile-link" title="{{displayedStatus.account.acct}}"
(click)="openAccount(displayedStatus.account)">
<img [class.status__avatar--boosted]="reblog" class="status__avatar"
<img [class.status__avatar--boosted]="reblog || notificationAccount" class="status__avatar"
src="{{ displayedStatus.account.avatar }}" />
<!-- <img *ngIf="reblog" class="status__avatar--reblog" src="{{ status.account.avatar }}" /> -->
<img *ngIf="notificationAccount" class="notification--avatar" src="{{ notificationAccount.avatar }}" />
<span class="status__name">
<span class="status__name--displayname" innerHTML="{{displayedStatus.account.display_name}}"></span><span
<span class="status__name--displayname"
innerHTML="{{displayedStatus.account.display_name}}"></span><span
class="status__name--username">{{displayedStatus.account.acct}}</span>
</span>
</a>
@ -23,13 +42,15 @@
<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">
<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">
<div class="status__labels--label status__labels--discuss" title="this status has a discution"
*ngIf="hasReply">
replies
</div>
</div>
@ -49,6 +70,7 @@
<app-action-bar #appActionBar [statusWrapper]="statusWrapper" (cwIsActiveEvent)="changeCw($event)"
(replyEvent)="openReply()"></app-action-bar>
<app-create-status *ngIf="replyingToStatus" [statusReplyingToWrapper]="statusWrapper" (onClose)="closeReply()"></app-create-status>
</div>
<app-create-status *ngIf="replyingToStatus" [statusReplyingToWrapper]="statusWrapper"
(onClose)="closeReply()"></app-create-status>
</div>

View File

@ -154,3 +154,52 @@
display: block; // width: calc(100% - 80px);
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 { faStar, faRetweet } from "@fortawesome/free-solid-svg-icons";
import { Status, Account } from "../../../services/models/mastodon.interfaces";
import { OpenThreadEvent } from "../../../services/tools.service";
import { ActionBarComponent } from "./action-bar/action-bar.component";
@ -10,6 +12,9 @@ import { StatusWrapper } from '../../../models/common.model';
styleUrls: ["./status.component.scss"]
})
export class StatusComponent implements OnInit {
faStar = faStar;
faRetweet = faRetweet;
displayedStatus: Status;
reblog: boolean;
hasAttachments: boolean;
@ -27,6 +32,9 @@ export class StatusComponent implements OnInit {
@Input() isThreadDisplay: boolean;
@Input() notificationType: 'mention' | 'reblog' | 'favourite';
@Input() notificationAccount: Account;
private _statusWrapper: StatusWrapper;
status: Status;
@Input('statusWrapper')

View File

@ -1,4 +1,8 @@
<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>
<!-- data-simplebar -->

View File

@ -1,19 +1,49 @@
@import "variables";
@import "commons";
@import "mixins";
.stream-toots {
height: calc(100%);
width: calc(100%);
overflow: auto;
&__error {
padding: 20px 20px 0 20px;
color: rgb(255, 113, 113);
}
&__status:not(:last-child) {
border: solid #06070b;
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;
isThread = false;
displayError: string;
hasContentWarnings = false;
private _streamElement: StreamElement;
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 { 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 { AccountInfo } from '../../../states/accounts.state';
import { StatusWrapper } from '../../../models/common.model';
import { StatusComponent } from '../status/status.component';
@Component({
selector: 'app-thread',
@ -18,6 +19,7 @@ export class ThreadComponent implements OnInit {
displayError: string;
isLoading = true;
isThread = true;
hasContentWarnings = false;
private lastThreadEvent: OpenThreadEvent;
@ -33,6 +35,8 @@ export class ThreadComponent implements OnInit {
}
}
@ViewChildren(StatusComponent) statusChildren: QueryList<StatusComponent>;
constructor(
private readonly notificationService: NotificationService,
private readonly toolsService: ToolsService,
@ -86,6 +90,8 @@ export class ThreadComponent implements OnInit {
const wrapper = new StatusWrapper(s, currentAccount);
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 {
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)
.then((account: Account) => {
console.warn(account);
this.isLoading = false;
this.statusLoading = true;

View File

@ -1,8 +1,8 @@
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 { 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 { StreamTypeEnum } from '../states/streams.state';
@ -134,6 +134,27 @@ export class MastodonService {
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[]> {
const route = `https://${account.instance}${this.apiRoutes.searchForAccounts}?q=${query}&limit=${limit}&following=${following}`;
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()
}
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 headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
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 headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.post<Status>(route, null, { headers: headers }).toPromise()
}
getRelationships(account: AccountInfo, accountsToRetrieve: Account[]): Promise<Relationship[]> {
let params = `?${this.formatArray(accountsToRetrieve.map(x => x.id.toString()), 'id')}`;
@ -210,10 +229,30 @@ export class MastodonService {
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 {
let result = '';
data.forEach(x => {
if (result.includes('paramName')) result += '&';
if (result.includes(paramName)) result += '&';
result += `${paramName}[]=${x}`;
});
return result;
@ -236,3 +275,9 @@ class StatusData {
spoiler_text: 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;
token_type: string;
scope: string;
created_at: string;
created_at: number;
//TODO: Pleroma support this
me: string;
expires_in: number;
refresh_token: string;
}
export interface Account {

View File

@ -5,6 +5,7 @@ import { AccountInfo } from '../states/accounts.state';
import { MastodonService } from './mastodon.service';
import { Account, Results, Status } from "./models/mastodon.interfaces";
import { StatusWrapper } from '../models/common.model';
import { AccountSettings, SaveAccountSettings } from '../states/settings.state';
@Injectable({
providedIn: 'root'
@ -21,6 +22,23 @@ export class ToolsService {
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> {
return this.mastodonService.search(account, accountName, true)
.then((result: Results) => {
@ -42,7 +60,7 @@ export class ToolsService {
if (!isProvider) {
statusPromise = statusPromise.then((foreignStatus: Status) => {
const statusUrl = foreignStatus.url;
return this.mastodonService.search(account, statusUrl)
return this.mastodonService.search(account, statusUrl, true)
.then((results: Results) => {
return results.statuses[0];
});
@ -51,6 +69,7 @@ export class ToolsService {
return statusPromise;
}
}
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%);
height: calc(100%);
padding: 10px 10px 0 7px;
font-size: $small-font-size;
font-size: $small-font-size; //FIXME: remove this
white-space: normal;
// overflow: auto;
&__title {
font-size: 13px;
text-transform: uppercase;

View File

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