Merge pull request #240 from NicolasConstant/develop

0.22.0 PR
This commit is contained in:
Nicolas Constant 2020-03-14 14:23:52 -04:00 committed by GitHub
commit d1a85d05c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 476 additions and 288 deletions

View File

@ -30,6 +30,8 @@ test_script:
$wc.UploadFile("https://ci.appveyor.com/api/testresults/junit/$($env:APPVEYOR_JOB_ID)", (Resolve-Path $_))
}
- npm run dist
- ps: >-
Remove-Item 'C:\projects\sengi\dist\assets\emoji' -Recurse
artifacts:
- path: dist
deploy:
@ -42,4 +44,4 @@ deploy:
folder: /
application: dist.zip
on:
branch: master
branch: master

View File

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

View File

@ -81,6 +81,7 @@ import { StreamNotificationsComponent } from './components/stream/stream-notific
import { NotificationComponent } from './components/floating-column/manage-account/notifications/notification/notification.component';
import { ServiceWorkerModule } from '@angular/service-worker';
import { environment } from '../environments/environment';
import { BookmarksComponent } from './components/floating-column/manage-account/bookmarks/bookmarks.component';
const routes: Routes = [
@ -142,7 +143,8 @@ const routes: Routes = [
ScheduledStatusesComponent,
ScheduledStatusComponent,
StreamNotificationsComponent,
NotificationComponent
NotificationComponent,
BookmarksComponent
],
entryComponents: [
EmojiPickerComponent

View File

@ -15,13 +15,16 @@
</div>
<app-manage-account *ngIf="openPanel === 'manageAccount'" [account]="userAccountUsed"
(browseAccountEvent)="browseAccount($event)" (browseHashtagEvent)="browseHashtag($event)"
(browseAccountEvent)="browseAccount($event)"
(browseHashtagEvent)="browseHashtag($event)"
(browseThreadEvent)="browseThread($event)"></app-manage-account>
<app-add-new-status *ngIf="openPanel === 'createNewStatus'" [isDirectMention]="isDirectMention"
[userHandle]="userHandle" [redraftedStatus]="redraftedStatus"></app-add-new-status>
<app-add-new-account *ngIf="openPanel === 'addNewAccount'"></app-add-new-account>
<app-search *ngIf="openPanel === 'search'" (browseAccountEvent)="browseAccount($event)"
(browseHashtagEvent)="browseHashtag($event)" (browseThreadEvent)="browseThread($event)">
<app-search *ngIf="openPanel === 'search'"
(browseAccountEvent)="browseAccount($event)"
(browseHashtagEvent)="browseHashtag($event)"
(browseThreadEvent)="browseThread($event)">
</app-search>
<app-settings *ngIf="openPanel === 'settings'"></app-settings>
<app-scheduled-statuses *ngIf="openPanel === 'scheduledStatuses'"></app-scheduled-statuses>

View File

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

View File

@ -1,20 +1,20 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { RegisterNewAccountComponent } from './register-new-account.component';
import { BookmarksComponent } from './bookmarks.component';
xdescribe('RegisterNewAccountComponent', () => {
let component: RegisterNewAccountComponent;
let fixture: ComponentFixture<RegisterNewAccountComponent>;
xdescribe('BookmarksComponent', () => {
let component: BookmarksComponent;
let fixture: ComponentFixture<BookmarksComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ RegisterNewAccountComponent ]
declarations: [ BookmarksComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(RegisterNewAccountComponent);
fixture = TestBed.createComponent(BookmarksComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

@ -0,0 +1,125 @@
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 { FavoriteResult, BookmarkResult } from '../../../../services/mastodon.service';
import { MastodonWrapperService } from '../../../../services/mastodon-wrapper.service';
import { Status } from '../../../../services/models/mastodon.interfaces';
import { NotificationService } from '../../../../services/notification.service';
@Component({
selector: 'app-bookmarks',
templateUrl: '../../../stream/stream-statuses/stream-statuses.component.html',
styleUrls: ['../../../stream/stream-statuses/stream-statuses.component.scss', './bookmarks.component.scss']
})
export class BookmarksComponent implements OnInit {
statuses: StatusWrapper[] = [];
displayError: string;
isLoading = true;
isThread = false;
hasContentWarnings = false;
bufferStream: Status[] = []; //html compatibility only
@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.getBookmarks();
}
get account(): AccountWrapper {
return this._account;
}
@ViewChild('statusstream') public statustream: ElementRef;
constructor(
private readonly notificationService: NotificationService,
private readonly mastodonService: MastodonWrapperService) { }
ngOnInit() {
}
private reset() {
this.isLoading = true;
this.statuses.length = 0;
this.maxReached = false;
this.maxId = null;
}
private getBookmarks() {
this.reset();
this.mastodonService.getBookmarks(this.account.info)
.then((result: BookmarkResult) => {
this.maxId = result.max_id;
for (const s of result.bookmarked) {
const wrapper = new StatusWrapper(s, this.account.info);
this.statuses.push(wrapper);
}
})
.catch(err => {
this.notificationService.notifyHttpError(err, this.account.info);
})
.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.getBookmarks(this.account.info, this.maxId)
.then((result: BookmarkResult) => {
const statuses = result.bookmarked;
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, this.account.info);
})
.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

@ -7,7 +7,6 @@ import { FavoriteResult } from '../../../../services/mastodon.service';
import { MastodonWrapperService } from '../../../../services/mastodon-wrapper.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',

View File

@ -3,10 +3,16 @@
<div class="account__header">
<a href (click)="browseLocalAccount()" (auxclick)="openLocalAccount()" title="open {{ account.info.id }}">
<img class="account__avatar" src="{{account.avatar}}"/>
<img class="account__avatar" src="{{account.avatar}}" />
</a>
<!-- <a href class="account__header--button"><fa-icon [icon]="faUserPlus"></fa-icon></a> -->
<a *ngIf="isBookmarksAvailable" href class="account__header--button" title="bookmark"
(click)="loadSubPanel('bookmarks')"
[ngClass]="{ 'account__header--button--selected': subPanel === 'bookmarks' }">
<fa-icon [icon]="faBookmark"></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>
@ -29,6 +35,9 @@
</a>
</div>
<app-bookmarks class="account__body" *ngIf="subPanel === 'bookmarks'" [account]="account"
(browseAccountEvent)="browseAccount($event)" (browseHashtagEvent)="browseHashtag($event)"
(browseThreadEvent)="browseThread($event)"></app-bookmarks>
<app-direct-messages class="account__body" *ngIf="subPanel === 'dm'" [account]="account"
(browseAccountEvent)="browseAccount($event)" (browseHashtagEvent)="browseHashtag($event)"
(browseThreadEvent)="browseThread($event)"></app-direct-messages>

View File

@ -1,11 +1,11 @@
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 { faBell, faEnvelope, faUser, faStar, faBookmark } from "@fortawesome/free-regular-svg-icons";
import { Subscription } from 'rxjs';
import { AccountWrapper } from '../../../models/account.models';
import { UserNotificationService, UserNotification } from '../../../services/user-notification.service';
import { OpenThreadEvent, ToolsService } from '../../../services/tools.service';
import { OpenThreadEvent, ToolsService, InstanceInfo } from '../../../services/tools.service';
import { MastodonWrapperService } from '../../../services/mastodon-wrapper.service';
import { Account } from "../../../services/models/mastodon.interfaces";
import { NotificationService } from '../../../services/notification.service';
@ -24,10 +24,12 @@ export class ManageAccountComponent implements OnInit, OnDestroy {
faUser = faUser;
faStar = faStar;
faUserPlus = faUserPlus;
faBookmark = faBookmark;
subPanel: 'account' | 'notifications' | 'mentions' | 'dm' | 'favorites' = 'account';
subPanel: 'account' | 'notifications' | 'mentions' | 'dm' | 'favorites' | 'bookmarks' = 'account';
hasNotifications = false;
hasMentions = false;
isBookmarksAvailable = false;
userAccount: Account;
@ -38,6 +40,7 @@ export class ManageAccountComponent implements OnInit, OnDestroy {
@Input('account')
set account(acc: AccountWrapper) {
this._account = acc;
this.checkIfBookmarksAreAvailable();
this.checkNotifications();
this.getUserUrl(acc.info);
}
@ -54,13 +57,27 @@ export class ManageAccountComponent implements OnInit, OnDestroy {
private readonly notificationService: NotificationService,
private readonly userNotificationService: UserNotificationService) { }
ngOnInit() {
ngOnInit() {
}
ngOnDestroy(): void {
this.userNotificationServiceSub.unsubscribe();
}
private checkIfBookmarksAreAvailable() {
this.toolsService.getInstanceInfo(this.account.info)
.then((instance: InstanceInfo) => {
if (instance.major >= 3 && instance.minor >= 1) {
this.isBookmarksAvailable = true;
} else {
this.isBookmarksAvailable = false;
}
})
.catch(err => {
this.isBookmarksAvailable = false;
});
}
private getUserUrl(account: AccountInfo) {
this.mastodonService.retrieveAccountDetails(this.account.info)
.then((acc: Account) => {

View File

@ -16,7 +16,7 @@
<div *ngIf="accounts.length > 0" class="search-results">
<h3 class="search-results__title">Accounts</h3>
<a href *ngFor="let account of accounts" class="account" title="open account"
(click)="browseAccount(account)">
(click)="processAndBrowseAccount(account)">
<img src="{{account.avatar}}" class="account__avatar" />
<div class="account__name">{{ account.username }}</div>
<div class="account__fullhandle">@{{ account.acct }}</div>
@ -35,8 +35,10 @@
<h3 class="search-results__title">Statuses</h3>
<div class="search-results__status" *ngFor="let statusWrapper of statuses">
<app-status [statusWrapper]="statusWrapper" (browseAccountEvent)="browseAccount($event)"
(browseHashtagEvent)="browseHashtag($event)" (browseThreadEvent)="browseThread($event)">
<app-status [statusWrapper]="statusWrapper"
(browseAccountEvent)="browseAccount($event)"
(browseHashtagEvent)="browseHashtag($event)"
(browseThreadEvent)="browseThread($event)">
</app-status>
</div>
</div>

View File

@ -54,15 +54,24 @@ export class SearchComponent implements OnInit {
return false;
}
browseAccount(account: Account): boolean {
let accountName = this.toolsService.getAccountFullHandle(account);
this.browseAccountEvent.next(accountName);
browseAccount(account: string): boolean {
if (account) {
this.browseAccountEvent.next(account);
}
return false;
}
processAndBrowseAccount(account: Account): boolean {
if(account){
const fullHandle = this.toolsService.getAccountFullHandle(account);
this.browseAccountEvent.next(fullHandle);
}
return false;
}
private lastAccountUsed: AccountInfo;
private search(data: string) {
if(!data) return;
if (!data) return;
this.accounts.length = 0;
this.statuses.length = 0;

View File

@ -11,7 +11,8 @@
<fa-icon *ngIf="isBoostLocked && !isLocked && !isDM" class="action-bar__lock" title="This post cannot be boosted"
[icon]="faLock"></fa-icon>
<fa-icon *ngIf="isLocked" class="action-bar__lock" title="Account can't access this post" [icon]="faLock"></fa-icon>
<fa-icon *ngIf="isDM && !isLocked" class="action-bar__envelope" title="DM post cannot be boosted" [icon]="faEnvelope"></fa-icon>
<fa-icon *ngIf="isDM && !isLocked" class="action-bar__envelope" title="DM post cannot be boosted"
[icon]="faEnvelope"></fa-icon>
<a *ngIf="!isLocked" href class="action-bar__link action-bar__link--fav" title="Favourite"
[class.favorited]="isFavorited" [class.favoriting]="favoriteIsLoading" (click)="favorite()">
@ -19,6 +20,12 @@
</a>
<fa-icon *ngIf="isLocked" class="action-bar__lock" title="Account can't access this post" [icon]="faLock"></fa-icon>
<a *ngIf="isBookmarksAvailable && !isLocked" href class="action-bar__link action-bar__link--bookmark" title="Bookmark"
[class.bookmarked]="isBookmarked" [class.favoriting]="bookmarkingIsLoading" (click)="bookmark()">
<fa-icon [icon]="faBookmark"></fa-icon>
</a>
<fa-icon *ngIf="isBookmarksAvailable && isLocked" class="action-bar__lock" title="Account can't access this post" [icon]="faLock"></fa-icon>
<a href class="action-bar__link action-bar__link--cw" title="show content" (click)="showContent()"
*ngIf="isContentWarningActive">
<fa-icon [icon]="faWindowClose"></fa-icon>
@ -28,48 +35,6 @@
<fa-icon [icon]="faWindowCloseRegular"></fa-icon>
</a>
<app-status-user-context-menu class="action-bar__link action-bar__link--more" [statusWrapper]="statusWrapper" (browseThreadEvent)="browseThread($event)"></app-status-user-context-menu>
<!-- <a href class="action-bar__link action-bar__link--more" (click)="onContextMenu($event)" title="More">
<fa-icon [icon]="faEllipsisH"></fa-icon>
</a>
<context-menu #contextMenu>
<ng-template contextMenuItem (execute)="expandStatus()">
Expand status
</ng-template>
<ng-template contextMenuItem (execute)="copyStatusLink()">
Copy link to status
</ng-template>
<ng-template contextMenuItem divider="true"></ng-template>
<ng-template contextMenuItem (execute)="mentionAccount()" *ngIf="!isOwnerSelected">
Mention @{{ this.username }}
</ng-template>
<ng-template contextMenuItem (execute)="dmAccount()" *ngIf="!isOwnerSelected">
Direct message @{{ this.username }}
</ng-template>
<ng-template contextMenuItem (execute)="muteConversation()" *ngIf="isOwnerSelected && !displayedStatus.muted">
Mute conversation
</ng-template>
<ng-template contextMenuItem (execute)="unmuteConversation()" *ngIf="isOwnerSelected && displayedStatus.muted">
Unmute conversation
</ng-template>
<ng-template contextMenuItem divider="true"></ng-template>
<ng-template contextMenuItem (execute)="muteAccount()" *ngIf="!isOwnerSelected">
Mute @{{ this.username }}
</ng-template>
<ng-template contextMenuItem (execute)="blockAccount()" *ngIf="!isOwnerSelected">
Block @{{ this.username }}
</ng-template>
<ng-template contextMenuItem (execute)="pinOnProfile()" *ngIf="isOwnerSelected && !displayedStatus.pinned && displayedStatus.visibility === 'public'">
Pin on profile
</ng-template>
<ng-template contextMenuItem (execute)="unpinFromProfile()" *ngIf="isOwnerSelected && displayedStatus.pinned && displayedStatus.visibility === 'public'">
Unpin from profile
</ng-template>
<ng-template contextMenuItem (execute)="delete(false)" *ngIf="isOwnerSelected">
Delete
</ng-template>
<ng-template contextMenuItem (execute)="delete(true)" *ngIf="isOwnerSelected">
Delete & re-draft
</ng-template>
</context-menu> -->
<app-status-user-context-menu class="action-bar__link action-bar__link--more" [statusWrapper]="statusWrapper"
(browseThreadEvent)="browseThread($event)"></app-status-user-context-menu>
</div>

View File

@ -1,7 +1,7 @@
@import "variables";
.action-bar {
// outline: 1px solid greenyellow; // height: 20px;
//outline: 1px solid greenyellow; // height: 20px;
margin: 5px 0px 5px $avatar-column-space;
padding: 0;
font-size: 18px;
@ -10,17 +10,18 @@
// transform: rotate(0.03deg);
&__link {
//outline: 1px solid greenyellow;
color: $status-secondary-color;
padding: 0 4px;
&:hover {
color: $status-links-color;
}
&:not(:last-child) {
margin-right: 15px;
margin-right: 8px;
}
&--reply {
font-size: 20px;
}
@ -34,6 +35,14 @@
&--fav {
font-size: 17px;
}
&--bookmark {
display: inline-block;
position: relative;
padding: 0 8px;
// bottom: -1px;
font-size: 16px;
}
&--cw {
position: relative;
@ -86,6 +95,13 @@
}
}
.bookmarked {
color: $bookmarked-color;
&:hover {
color: darken($bookmarked-color, 10);
}
}
@keyframes loadingAnimation {
0% {

View File

@ -2,13 +2,13 @@ import { Component, OnInit, OnDestroy, Input, Output, EventEmitter } from '@angu
import { HttpErrorResponse } from '@angular/common/http';
import { Store } from '@ngxs/store';
import { Observable, Subscription } from 'rxjs';
import { faWindowClose, faReply, faRetweet, faStar, faEllipsisH, faLock, faEnvelope } from "@fortawesome/free-solid-svg-icons";
import { faWindowClose, faReply, faRetweet, faStar, faEllipsisH, faLock, faEnvelope, faBookmark } from "@fortawesome/free-solid-svg-icons";
import { faWindowClose as faWindowCloseRegular } from "@fortawesome/free-regular-svg-icons";
import { MastodonWrapperService } from '../../../../services/mastodon-wrapper.service';
import { AccountInfo } from '../../../../states/accounts.state';
import { Status, Account, Results } from '../../../../services/models/mastodon.interfaces';
import { ToolsService, OpenThreadEvent } from '../../../../services/tools.service';
import { ToolsService, OpenThreadEvent, InstanceInfo } from '../../../../services/tools.service';
import { NotificationService } from '../../../../services/notification.service';
import { StatusWrapper } from '../../../../models/common.model';
import { StatusesStateService, StatusState } from '../../../../services/statuses-state.service';
@ -27,6 +27,7 @@ export class ActionBarComponent implements OnInit, OnDestroy {
faEllipsisH = faEllipsisH;
faLock = faLock;
faEnvelope = faEnvelope;
faBookmark = faBookmark;
@Input() statusWrapper: StatusWrapper;
@Output() replyEvent = new EventEmitter();
@ -34,13 +35,16 @@ export class ActionBarComponent implements OnInit, OnDestroy {
@Output() browseThreadEvent = new EventEmitter<OpenThreadEvent>();
isBookmarked: boolean;
isFavorited: boolean;
isBoosted: boolean;
isDM: boolean;
isBoostLocked: boolean;
isLocked: boolean;
isBookmarksAvailable: boolean;
bookmarkingIsLoading: boolean;
favoriteIsLoading: boolean;
boostIsLoading: boolean;
@ -53,6 +57,7 @@ export class ActionBarComponent implements OnInit, OnDestroy {
private favoriteStatePerAccountId: { [id: string]: boolean; } = {};
private bootedStatePerAccountId: { [id: string]: boolean; } = {};
private bookmarkStatePerAccountId: { [id: string]: boolean; } = {};
private accounts$: Observable<AccountInfo[]>;
private accountSub: Subscription;
@ -69,19 +74,17 @@ export class ActionBarComponent implements OnInit, OnDestroy {
}
ngOnInit() {
const status = this.statusWrapper.status;
this.displayedStatus = this.statusWrapper.status;
const account = this.statusWrapper.provider;
if (status.reblog) {
this.favoriteStatePerAccountId[account.id] = status.reblog.favourited;
this.bootedStatePerAccountId[account.id] = status.reblog.reblogged;
this.displayedStatus = status.reblog;
} else {
this.favoriteStatePerAccountId[account.id] = status.favourited;
this.bootedStatePerAccountId[account.id] = status.reblogged;
this.displayedStatus = status;
if (this.displayedStatus.reblog) {
this.displayedStatus = this.displayedStatus.reblog;
}
this.favoriteStatePerAccountId[account.id] = this.displayedStatus.favourited;
this.bootedStatePerAccountId[account.id] = this.displayedStatus.reblogged;
this.bookmarkStatePerAccountId[account.id] = this.displayedStatus.bookmarked;
this.analyseMemoryStatus();
if (this.displayedStatus.visibility === 'direct') {
@ -94,11 +97,20 @@ export class ActionBarComponent implements OnInit, OnDestroy {
this.statusStateSub = this.statusStateService.stateNotification.subscribe((state: StatusState) => {
if (state && state.statusId === this.displayedStatus.url) {
this.favoriteStatePerAccountId[state.accountId] = state.isFavorited;
this.bootedStatePerAccountId[state.accountId] = state.isRebloged;
if (state.isFavorited) {
this.favoriteStatePerAccountId[state.accountId] = state.isFavorited;
}
if (state.isRebloged) {
this.bootedStatePerAccountId[state.accountId] = state.isRebloged;
}
if (state.isBookmarked) {
this.bookmarkStatePerAccountId[state.accountId] = state.isBookmarked;
}
this.checkIfFavorited();
this.checkIfBoosted();
this.checkIfBookmarked();
}
});
}
@ -115,6 +127,7 @@ export class ActionBarComponent implements OnInit, OnDestroy {
memoryStatusState.forEach((state: StatusState) => {
this.favoriteStatePerAccountId[state.accountId] = state.isFavorited;
this.bootedStatePerAccountId[state.accountId] = state.isRebloged;
this.bookmarkStatePerAccountId[state.accountId] = state.isBookmarked;
});
}
@ -140,10 +153,13 @@ export class ActionBarComponent implements OnInit, OnDestroy {
this.isContentWarningActive = true;
}
this.checkIfBookmarksAreAvailable(this.selectedAccounts[0]);
this.checkIfFavorited();
this.checkIfBoosted();
this.checkIfBookmarked();
}
showContent(): boolean {
this.isContentWarningActive = false;
this.cwIsActiveEvent.next(false);
@ -236,6 +252,49 @@ export class ActionBarComponent implements OnInit, OnDestroy {
return false;
}
bookmark(): boolean {
if (this.bookmarkingIsLoading) return;
this.bookmarkingIsLoading = true;
const account = this.toolsService.getSelectedAccounts()[0];
const usableStatus = this.toolsService.getStatusUsableByAccount(account, this.statusWrapper);
usableStatus
.then((status: Status) => {
if (this.isBookmarked && status.bookmarked) {
return this.mastodonService.unbookmark(account, status);
} else if (!this.isBookmarked && !status.bookmarked) {
return this.mastodonService.bookmark(account, status);
} else {
return Promise.resolve(status);
}
})
.then((bookmarkedStatus: Status) => {
let bookmarked = bookmarkedStatus.bookmarked; //FIXME: when pixelfed will return the good status
if (bookmarked === null) {
bookmarked = !this.bookmarkStatePerAccountId[account.id];
}
this.bookmarkStatePerAccountId[account.id] = bookmarked;
this.checkIfBookmarked();
})
.catch((err: HttpErrorResponse) => {
this.notificationService.notifyHttpError(err, account);
})
.then(() => {
this.statusStateService.statusBookmarkStatusChanged(this.displayedStatus.url, account.id, this.bookmarkStatePerAccountId[account.id]);
this.bookmarkingIsLoading = false;
});
// setTimeout(() => {
// this.isBookmarked = !this.isBookmarked;
// this.bookmarkingIsLoading = false;
// }, 2000);
return false;
}
private checkIfBoosted() {
const selectedAccount = <AccountInfo>this.selectedAccounts[0];
if (selectedAccount) {
@ -255,6 +314,30 @@ export class ActionBarComponent implements OnInit, OnDestroy {
}
}
private checkIfBookmarked() {
const selectedAccount = <AccountInfo>this.selectedAccounts[0];
if (selectedAccount) {
this.isBookmarked = this.bookmarkStatePerAccountId[selectedAccount.id];
} else {
this.isBookmarked = false;
}
}
private checkIfBookmarksAreAvailable(account: AccountInfo) {
this.toolsService.getInstanceInfo(account)
.then((instance: InstanceInfo) => {
if (instance.major >= 3 && instance.minor >= 1) {
this.isBookmarksAvailable = true;
} else {
this.isBookmarksAvailable = false;
}
})
.catch(err => {
this.isBookmarksAvailable = false;
});
}
browseThread(event: OpenThreadEvent) {
this.browseThreadEvent.next(event);
}

View File

@ -1,5 +1,5 @@
<div class="card-data" *ngIf="card.type === 'link' || card.type === 'video'">
<a *ngIf="card.type === 'link'" class="card-data__link" href="{{ card.url }}" target="_blank" title="{{ card.title }}">
<a *ngIf="card.type === 'link'" class="card-data__link" href="{{ card.url }}" target="_blank" title="{{ card.title }} &#10;{{ host }}">
<img *ngIf="card.image" class="card-data__link--image" src="{{ card.image }}" alt="" />
<div *ngIf="!card.image" class="card-data__link--image">
<fa-icon class="card-data__link--image--logo" [icon]="faFileAlt"></fa-icon>

View File

@ -16,7 +16,8 @@
display: block;
text-decoration: none;
color: whitesmoke;
overflow: auto;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid $card-border-color;
@ -50,15 +51,14 @@
margin: 11px 0 0 0px;
font-size: 0.8em;
opacity: .7;
text-overflow: ellipsis;
}
}
&__photo {}
//&__photo {}
&__video {
// border-radius: 3px;
$height-content: 150px;
@ -69,7 +69,6 @@
position: absolute;
top: 55px;
left: 65px;
// outline: 2px solid greenyellow;
width: 100px;
height: 40px;
z-index: 10;
@ -94,18 +93,16 @@
&--image {
width: 100%;
height: $height-content;
// border: 1px solid salmon;
object-fit: cover;
}
}
&--content {
//width: 300px;
width: 100%;
height: $height-content;
background-color: #000;
}
}
&__rich {}
// &__rich {}
}

View File

@ -1,6 +1,6 @@
<div class="outer-profile">
<div class="profile flexcroll" #statusstream (scroll)="onScroll()">
<div *ngIf="!isLoading" class="profile__floating-header"
<div *ngIf="!isLoading && displayedAccount" class="profile__floating-header"
[ngStyle]="{'background-image':'url('+displayedAccount.header+')'}"
[class.profile__floating-header__activated]="showFloatingHeader">
<div class="profile__floating-header__inner">
@ -21,7 +21,7 @@
class="waiting-icon profile-header__follow--waiting">
</app-waiting-animation>
<div *ngIf="!loadingRelationShip">
<div *ngIf="!loadingRelationShip && !relationShipError">
<a href class="profile-header__follow--button profile-header__follow--unfollowed" title="follow"
(click)="follow()" *ngIf="!relationship.following && !relationship.requested">
<fa-icon [icon]="faUserRegular"></fa-icon>
@ -35,12 +35,21 @@
<fa-icon [icon]="faHourglassHalf"></fa-icon>
</a>
</div>
<div *ngIf="!loadingRelationShip && relationShipError">
<a class="profile-header__follow--button profile-header__follow--followed" title="error when retrieving relationship">
<fa-icon [icon]="faExclamationTriangle"></fa-icon>
</a>
</div>
</div>
</div>
</div>
<app-waiting-animation *ngIf="isLoading" class="waiting-icon"></app-waiting-animation>
<div *ngIf="!isLoading && !displayedAccount" class="profile__not-found">
account couldn't be found.
</div>
<div class="profile__moved" *ngIf="displayedAccount && displayedAccount.moved">
<span innerHTML="{{displayedAccount | accountEmoji }}"></span> has moved to <br /><a href
(click)="openMigratedAccount(displayedAccount.moved)" class="profile__moved--link"
@ -64,7 +73,7 @@
class="waiting-icon profile-header__follow--waiting">
</app-waiting-animation>
<div *ngIf="!loadingRelationShip">
<div *ngIf="!loadingRelationShip && !relationShipError">
<a href class="profile-header__follow--button profile-header__follow--unfollowed" title="follow"
(click)="follow()" *ngIf="!relationship.following && !relationship.requested">
<fa-icon [icon]="faUserRegular"></fa-icon>
@ -78,6 +87,11 @@
<fa-icon [icon]="faHourglassHalf"></fa-icon>
</a>
</div>
<div *ngIf="!loadingRelationShip && relationShipError">
<a class="profile-header__follow--button profile-header__follow--followed" title="error when retrieving relationship">
<fa-icon [icon]="faExclamationTriangle"></fa-icon>
</a>
</div>
</div>
<div class="profile-header__state"
*ngIf="relationship && !displayedAccount.moved && !loadingRelationShip">
@ -97,7 +111,7 @@
</app-status-user-context-menu>
</div>
<div class="profile-sub-header">
<div *ngIf="displayedAccount" class="profile-sub-header">
<div *ngIf="displayedAccount">
<div class="profile-name">

View File

@ -21,6 +21,11 @@ $floating-header-height: 60px;
// width: $stream-column-width;
overflow: auto;
&__not-found {
padding-top: 15px;
text-align: center;
}
&__floating-header {
transition: all .2s;
transition-timing-function: ease-in;

View File

@ -1,6 +1,6 @@
import { Component, OnInit, Input, Output, EventEmitter, ViewChild, ElementRef } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { faUser, faHourglassHalf, faUserCheck } from "@fortawesome/free-solid-svg-icons";
import { faUser, faHourglassHalf, faUserCheck, faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
import { faUser as faUserRegular } from "@fortawesome/free-regular-svg-icons";
import { Observable, Subscription } from 'rxjs';
import { Store } from '@ngxs/store';
@ -26,6 +26,7 @@ export class UserProfileComponent implements OnInit {
faUserRegular = faUserRegular;
faHourglassHalf = faHourglassHalf;
faUserCheck = faUserCheck;
faExclamationTriangle = faExclamationTriangle;
displayedAccount: Account;
hasNote: boolean;
@ -34,6 +35,7 @@ export class UserProfileComponent implements OnInit {
isLoading: boolean;
loadingRelationShip = false;
relationShipError = false;
showFloatingHeader = false;
showFloatingStatusMenu = false;
@ -101,12 +103,16 @@ export class UserProfileComponent implements OnInit {
const userAccount = accounts.filter(x => x.isSelected)[0];
this.loadingRelationShip = true;
this.relationShipError = false;
this.toolsService.findAccount(userAccount, this.lastAccountName)
.then((account: Account) => {
if(!account) throw Error(`Could not find ${this.lastAccountName}`);
return this.getFollowStatus(userAccount, account);
})
.catch((err: HttpErrorResponse) => {
this.notificationService.notifyHttpError(err, userAccount);
.catch((err) => {
console.error(err);
this.relationShipError = true;
})
.then(() => {
this.loadingRelationShip = false;
@ -157,6 +163,8 @@ export class UserProfileComponent implements OnInit {
this.isLoading = false;
this.statusLoading = true;
if(!account) throw Error(`Could not find ${this.lastAccountName}`);
this.displayedAccount = this.fixPleromaFieldsUrl(account);
this.hasNote = account && account.note && account.note !== '<p></p>';
if (this.hasNote) {
@ -170,7 +178,7 @@ export class UserProfileComponent implements OnInit {
return Promise.all([getFollowStatusPromise, getStatusesPromise, getPinnedStatusesPromise]);
})
.catch((err: HttpErrorResponse) => {
this.notificationService.notifyHttpError(err, this.currentlyUsedAccount);
console.error(err);
})
.then(() => {
this.isLoading = false;

View File

@ -1,32 +0,0 @@
<div class="registering-account">
<div *ngIf="!hasError" class="registering-account__waiting">
<p>loading...</p>
</div>
<div *ngIf="hasError" class="registering-account__error">
<h3>Oooops!</h3>
<p>
{{ errorMessage }}
</p>
<a class="btn btn-info btn-sm" href title="close" [routerLink]="['/home']" role="button" style="float: right;">Quit</a>
</div>
</div>
<!--
<div class="col-xs-12 col-sm-6">
<br />
<h3>Adding new account...</h3>
<h4>please wait</h4>
<a class="btn btn-info btn-sm" href title="close" [routerLink]="['/home']" role="button" style="float: right;">close</a>
<br />
{{ result }}
</div> -->

View File

@ -1,8 +0,0 @@
.registering-account {
max-width: 400px;
margin: 20px auto;
padding: 20px;
word-wrap: break-word;
white-space: normal;
}

View File

@ -1,124 +0,0 @@
// import { Component, OnInit, Input } from "@angular/core";
// import { Store, Select } from '@ngxs/store';
// import { ActivatedRoute, Router } from "@angular/router";
// import { HttpErrorResponse } from "@angular/common/http";
// import { AuthService, CurrentAuthProcess } from "../../services/auth.service";
// import { TokenData, Account } from "../../services/models/mastodon.interfaces";
// import { RegisteredAppsStateModel, AppInfo } from "../../states/registered-apps.state";
// import { AccountInfo, AddAccount, AccountsStateModel } from "../../states/accounts.state";
// import { NotificationService } from "../../services/notification.service";
// import { MastodonWrapperService } from '../../services/mastodon-wrapper.service';
// @Component({
// selector: "app-register-new-account",
// templateUrl: "./register-new-account.component.html",
// styleUrls: ["./register-new-account.component.scss"]
// })
// export class RegisterNewAccountComponent implements OnInit {
// // @Input() mastodonFullHandle: string;
// hasError: boolean;
// errorMessage: string;
// private authStorageKey: string = 'tempAuth';
// constructor(
// private readonly mastodonService: MastodonWrapperService,
// private readonly notificationService: NotificationService,
// private readonly authService: AuthService,
// private readonly store: Store,
// private readonly activatedRoute: ActivatedRoute,
// private readonly router: Router) {
// this.activatedRoute.queryParams.subscribe(params => {
// this.hasError = false;
// const code = params['code'];
// if (!code) {
// this.displayError(RegistrationErrorTypes.CodeNotFound);
// return;
// }
// const appDataWrapper = <CurrentAuthProcess>JSON.parse(localStorage.getItem(this.authStorageKey));
// if (!appDataWrapper) {
// this.displayError(RegistrationErrorTypes.AuthProcessNotFound);
// return;
// }
// const appInfo = this.getAllSavedApps().filter(x => x.instance === appDataWrapper.instance)[0];
// let usedTokenData: TokenData;
// this.authService.getToken(appDataWrapper.instance, appInfo.app.client_id, appInfo.app.client_secret, code, appInfo.app.redirect_uri)
// .then((tokenData: TokenData) => {
// if(tokenData.refresh_token && !tokenData.created_at){
// const nowEpoch = Date.now() / 1000 | 0;
// tokenData.created_at = nowEpoch;
// }
// usedTokenData = tokenData;
// return this.mastodonService.retrieveAccountDetails({ 'instance': appDataWrapper.instance, 'id': '', 'username': '', 'order': 0, 'isSelected': true, 'token': tokenData });
// })
// .then((account: Account) => {
// var username = account.username.toLowerCase();
// var instance = appDataWrapper.instance.toLowerCase();
// if(this.isAccountAlreadyPresent(username, instance)){
// this.notificationService.notify(null, null, `Account @${username}@${instance} is already registered`, true);
// this.router.navigate(['/home']);
// return;
// }
// const accountInfo = new AccountInfo();
// accountInfo.username = username;
// accountInfo.instance = instance;
// accountInfo.token = usedTokenData;
// this.store.dispatch([new AddAccount(accountInfo)])
// .subscribe(() => {
// localStorage.removeItem(this.authStorageKey);
// this.router.navigate(['/home']);
// });
// })
// .catch((err: HttpErrorResponse) => {
// this.notificationService.notifyHttpError(err, null);
// });
// });
// }
// ngOnInit() {
// }
// private isAccountAlreadyPresent(username: string, instance: string): boolean{
// const accounts = <AccountInfo[]>this.store.snapshot().registeredaccounts.accounts;
// for (let acc of accounts) {
// if(acc.instance === instance && acc.username == username){
// return true;
// }
// }
// return false;
// }
// private displayError(type: RegistrationErrorTypes) {
// this.hasError = true;
// switch (type) {
// case RegistrationErrorTypes.AuthProcessNotFound:
// this.errorMessage = 'Something when wrong in the authentication process. Please retry.'
// break;
// case RegistrationErrorTypes.CodeNotFound:
// this.errorMessage = 'No authentication code returned. Please retry.'
// break;
// }
// }
// private getAllSavedApps(): AppInfo[] {
// const snapshot = <RegisteredAppsStateModel>this.store.snapshot().registeredapps;
// return snapshot.apps;
// }
// }
// enum RegistrationErrorTypes {
// CodeNotFound,
// AuthProcessNotFound
// }

View File

@ -4,7 +4,7 @@ import { Store } from '@ngxs/store';
import { Account, Status, Results, Context, Relationship, Instance, Attachment, Notification, List, Poll, Emoji, Conversation, ScheduledStatus, TokenData } from "./models/mastodon.interfaces";
import { AccountInfo, UpdateAccount } from '../states/accounts.state';
import { StreamTypeEnum, StreamElement } from '../states/streams.state';
import { FavoriteResult, VisibilityEnum, PollParameters, MastodonService } from './mastodon.service';
import { FavoriteResult, VisibilityEnum, PollParameters, MastodonService, BookmarkResult } from './mastodon.service';
import { AuthService } from './auth.service';
import { AppInfo, RegisteredAppsStateModel } from '../states/registered-apps.state';
@ -148,6 +148,13 @@ export class MastodonWrapperService {
});
}
getBookmarks(account: AccountInfo, maxId: string = null): Promise<BookmarkResult> {
return this.refreshAccountIfNeeded(account)
.then((refreshedAccount: AccountInfo) => {
return this.mastodonService.getBookmarks(refreshedAccount, maxId);
});
}
searchAccount(account: AccountInfo, query: string, limit: number = 40, following: boolean = false, resolve = true): Promise<Account[]> {
return this.refreshAccountIfNeeded(account)
.then((refreshedAccount: AccountInfo) => {
@ -183,6 +190,20 @@ export class MastodonWrapperService {
});
}
bookmark(account: AccountInfo, status: Status): Promise<Status> {
return this.refreshAccountIfNeeded(account)
.then((refreshedAccount: AccountInfo) => {
return this.mastodonService.bookmark(refreshedAccount, status);
});
}
unbookmark(account: AccountInfo, status: Status): Promise<Status> {
return this.refreshAccountIfNeeded(account)
.then((refreshedAccount: AccountInfo) => {
return this.mastodonService.unbookmark(refreshedAccount, status);
});
}
getRelationships(account: AccountInfo, accountsToRetrieve: Account[]): Promise<Relationship[]> {
return this.refreshAccountIfNeeded(account)
.then((refreshedAccount: AccountInfo) => {

View File

@ -188,6 +188,26 @@ export class MastodonService {
});
}
getBookmarks(account: AccountInfo, maxId: string = null): Promise<BookmarkResult> {
let route = `https://${account.instance}${this.apiRoutes.getBookmarks}`;
if (maxId) route += `?max_id=${maxId}`;
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 BookmarkResult(lastId, res.body);
});
}
searchAccount(account: AccountInfo, query: string, limit: number = 40, following: boolean = false, resolve = true): Promise<Account[]> {
const route = `https://${account.instance}${this.apiRoutes.searchForAccounts}?q=${query}&limit=${limit}&following=${following}&resolve=${resolve}`;
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
@ -218,6 +238,18 @@ export class MastodonService {
return this.httpClient.post<Status>(route, null, { headers: headers }).toPromise()
}
bookmark(account: AccountInfo, status: Status): Promise<Status> {
const route = `https://${account.instance}${this.apiRoutes.bookmarkingStatus}`.replace('{0}', status.id);
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.post<Status>(route, null, { headers: headers }).toPromise()
}
unbookmark(account: AccountInfo, status: Status): Promise<Status> {
const route = `https://${account.instance}${this.apiRoutes.unbookmarkingStatus}`.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')}`;
@ -451,4 +483,10 @@ export class FavoriteResult {
constructor(
public max_id: string,
public favorites: Status[]) {}
}
export class BookmarkResult {
constructor(
public max_id: string,
public bookmarked: Status[]) {}
}

View File

@ -70,4 +70,7 @@ export class ApiRoutes {
getScheduledStatuses = '/api/v1/scheduled_statuses';
putScheduleStatus = '/api/v1/scheduled_statuses/{0}';
deleteScheduleStatus = '/api/v1/scheduled_statuses/{0}';
bookmarkingStatus = '/api/v1/statuses/{0}/bookmark';
unbookmarkingStatus = '/api/v1/statuses/{0}/unbookmark';
getBookmarks = '/api/v1/bookmarks';
}

View File

@ -187,6 +187,7 @@ export interface Status {
language: string;
pinned: boolean;
muted: boolean;
bookmarked: boolean;
card: Card;
poll: Poll;

View File

@ -5,7 +5,7 @@ import { Subject } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class StatusesStateService {
export class StatusesStateService {
private cachedStatusStates: { [statusId: string]: { [accountId: string]: StatusState } } = {};
public stateNotification = new Subject<StatusState>();
@ -29,7 +29,7 @@ export class StatusesStateService {
this.cachedStatusStates[statusId] = {};
if (!this.cachedStatusStates[statusId][accountId]) {
this.cachedStatusStates[statusId][accountId] = new StatusState(statusId, accountId, isFavorited, false);
this.cachedStatusStates[statusId][accountId] = new StatusState(statusId, accountId, isFavorited, null, null);
} else {
this.cachedStatusStates[statusId][accountId].isFavorited = isFavorited;
}
@ -42,20 +42,35 @@ export class StatusesStateService {
this.cachedStatusStates[statusId] = {};
if (!this.cachedStatusStates[statusId][accountId]) {
this.cachedStatusStates[statusId][accountId] = new StatusState(statusId, accountId, false, isRebloged);
this.cachedStatusStates[statusId][accountId] = new StatusState(statusId, accountId, null, isRebloged, null);
} else {
this.cachedStatusStates[statusId][accountId].isRebloged = isRebloged;
}
this.stateNotification.next(this.cachedStatusStates[statusId][accountId]);
}
statusBookmarkStatusChanged(statusId: string, accountId: string, isBookmarked: boolean) {
if (!this.cachedStatusStates[statusId])
this.cachedStatusStates[statusId] = {};
if (!this.cachedStatusStates[statusId][accountId]) {
this.cachedStatusStates[statusId][accountId] = new StatusState(statusId, accountId, null, null, isBookmarked);
} else {
this.cachedStatusStates[statusId][accountId].isBookmarked = isBookmarked;
}
this.stateNotification.next(this.cachedStatusStates[statusId][accountId]);
}
}
export class StatusState {
constructor(
public statusId: string,
public accountId: string,
public isFavorited: boolean,
public isRebloged: boolean) {
public isRebloged: boolean,
public isBookmarked: boolean) {
}
}

View File

@ -11,7 +11,7 @@ import { AppInfo, RegisteredAppsStateModel } from '../states/registered-apps.sta
@Injectable({
providedIn: 'root'
})
export class ToolsService {
export class ToolsService {
private accountAvatar: { [id: string]: string; } = {};
private instanceInfos: { [id: string]: InstanceInfo } = {};
@ -99,31 +99,45 @@ export class ToolsService {
return settings;
}
saveSettings(settings: GlobalSettings){
saveSettings(settings: GlobalSettings) {
this.store.dispatch([
new SaveSettings(settings)
]);
}
findAccount(account: AccountInfo, accountName: string): Promise<Account> {
let findAccountFunc = (result: Results) => {
if (accountName[0] === '@') accountName = accountName.substr(1);
const foundAccount = result.accounts.find(
x => (x.acct.toLowerCase() === accountName.toLowerCase()
||
(x.acct.toLowerCase().split('@')[0] === accountName.toLowerCase().split('@')[0])
&& x.url.replace('https://', '').split('/')[0] === accountName.toLowerCase().split('@')[1])
);
return foundAccount;
};
let searchVersion: 'v1' | 'v2' = 'v1';
return this.getInstanceInfo(account)
.then(instance => {
let version: 'v1' | 'v2' = 'v1';
if (instance.major >= 3) version = 'v2';
return this.mastodonService.search(account, accountName, version, true);
//let version: 'v1' | 'v2' = 'v1';
if (instance.major >= 3) searchVersion = 'v2';
return this.mastodonService.search(account, accountName, searchVersion, true);
})
.then((result: Results) => {
if (accountName[0] === '@') accountName = accountName.substr(1);
.then((results: Results) => {
return findAccountFunc(results);
})
.then((foundAccount: Account) => {
if (foundAccount != null) return Promise.resolve(foundAccount);
const foundAccount = result.accounts.find(
x => (x.acct.toLowerCase() === accountName.toLowerCase()
||
(x.acct.toLowerCase().split('@')[0] === accountName.toLowerCase().split('@')[0])
&& x.url.replace('https://', '').split('/')[0] === accountName.toLowerCase().split('@')[1])
);
return foundAccount;
let fullName = `https://${accountName.split('@')[1]}/@${accountName.split('@')[0]}`;
return this.mastodonService.search(account, fullName, searchVersion, true)
.then((results: Results) => {
return findAccountFunc(results);
});
});
}
}
getStatusUsableByAccount(account: AccountInfo, originalStatus: StatusWrapper): Promise<Status> {
const isProvider = originalStatus.provider.id === account.id;

View File

@ -22,6 +22,7 @@ $status-secondary-color: #4e5572;
$status-links-color: #d9e1e8;
$boost-color : #5098eb;
$favorite-color: #ffc16f;
$bookmarked-color: #ff5050;
// Block dispositions
$scroll-bar-width: 8px;