Merge pull request #301 from NicolasConstant/topic_followers-follows

Topic followers follows
This commit is contained in:
Nicolas Constant 2020-06-17 05:30:13 +02:00 committed by GitHub
commit b0234435d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 508 additions and 66 deletions

View File

@ -84,6 +84,8 @@ import { environment } from '../environments/environment';
import { BookmarksComponent } from './components/floating-column/manage-account/bookmarks/bookmarks.component';
import { AttachementImageComponent } from './components/stream/status/attachements/attachement-image/attachement-image.component';
import { EnsureHttpsPipe } from './pipes/ensure-https.pipe';
import { UserFollowsComponent } from './components/stream/user-follows/user-follows.component';
import { AccountComponent } from './components/common/account/account.component';
const routes: Routes = [
@ -148,7 +150,9 @@ const routes: Routes = [
NotificationComponent,
BookmarksComponent,
AttachementImageComponent,
EnsureHttpsPipe
EnsureHttpsPipe,
UserFollowsComponent,
AccountComponent
],
entryComponents: [
EmojiPickerComponent

View File

@ -0,0 +1,5 @@
<a href class="account" title="open account" (click)="selected()">
<img src="{{account.avatar}}" class="account__avatar" />
<div class="account__name" innerHTML="{{ account | accountEmoji }}"></div>
<div class="account__fullhandle">@{{ account.acct }}</div>
</a>

View File

@ -0,0 +1,48 @@
@import "variables";
@import "mixins";
.account {
font-size: $small-font-size;
display: block;
color: white;
border-radius: 2px;
transition: all .3s;
border-top: 1px solid $separator-color;
overflow: hidden;
&:last-of-type {
border-bottom: 1px solid $separator-color;
}
&__avatar {
width: 40px;
margin: 5px 10px 5px 5px;
float: left;
border-radius: 2px;
}
&__name {
margin: 7px 0 0 0;
}
&__fullhandle {
margin: 0 0 5px 0;
color: $status-secondary-color;
transition: all .3s;
white-space: nowrap;
}
&:hover,
&:hover &__fullhandle {
color: white;
text-decoration: none;
}
&:hover {
background-color: $button-background-color-hover;
}
@include clearfix;
}

View File

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

View File

@ -0,0 +1,24 @@
import { Component, OnInit, Output, EventEmitter, Input } from '@angular/core';
import { Account } from '../../../services/models/mastodon.interfaces';
@Component({
selector: 'app-account',
templateUrl: './account.component.html',
styleUrls: ['./account.component.scss']
})
export class AccountComponent implements OnInit {
@Input() account: Account;
@Output() accountSelected = new EventEmitter<Account>();
constructor() { }
ngOnInit() {
}
selected(): boolean{
this.accountSelected.next(this.account);
return false;
}
}

View File

@ -15,12 +15,17 @@
<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"
<app-account class="account" *ngFor="let account of accounts"
[account]="account"
(accountSelected)="processAndBrowseAccount($event)"></app-account>
<!-- <a href *ngFor="let account of accounts" class="account" title="open 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>
</a>
</a> -->
</div>
<div *ngIf="hashtags.length > 0" class="search-results">

View File

@ -96,7 +96,7 @@ $search-form-height: 70px;
// outline: 1px solid greenyellow;
margin-top: 10px; // &:first-of-type{
padding-left: 10px; // margin-top: 10px;
padding-right: 10px; // margin-top: 10px;
//padding-right: 10px; // margin-top: 10px;
// }
&__title {
@ -135,42 +135,4 @@ $search-form-height: 70px;
.account {
display: block;
color: white;
border-radius: 2px;
transition: all .3s; // &:hover &__name {
// text-decoration: underline;
// }
border-top: 1px solid $separator-color;
&:last-of-type {
border-bottom: 1px solid $separator-color;
}
&__avatar {
width: 40px;
margin: 5px 10px 5px 5px;
float: left;
border-radius: 2px;
}
&__name {
margin: 7px 0 0 0;
}
&__fullhandle {
margin: 0 0 5px 0;
color: $status-secondary-color;
transition: all .3s; // &:hover {
// color: white;
// }
}
&:hover,
&:hover &__fullhandle {
color: white;
text-decoration: none;
background-color: $button-background-color-hover;
}
@include clearfix;
}

View File

@ -31,7 +31,16 @@
class="overlay__content"
(browseAccountEvent)="browseAccount($event)"
(browseHashtagEvent)="browseHashtag($event)"
(browseThreadEvent)="browseThread($event)"></app-user-profile>
(browseThreadEvent)="browseThread($event)"
(browseFollowsEvent)="browseFollows($event)"
(browseFollowersEvent)="browseFollowers($event)"></app-user-profile>
<app-user-follows *ngIf="e.type === 'follows' || e.type === 'followers'"
[currentAccount]="e.account"
[type]="e.type"
[refreshEventEmitter]="e.refreshEventEmitter"
[goToTopEventEmitter]="e.goToTopEventEmitter"
class="overlay__content"
(browseAccountEvent)="browseAccount($event)"></app-user-follows>
<app-hashtag #appHashtag *ngIf="e.type === 'hashtag'"
[hashtagElement]="e.hashtag"
[refreshEventEmitter]="e.refreshEventEmitter"

View File

@ -123,7 +123,7 @@ export class StreamOverlayComponent implements OnInit, OnDestroy {
browseAccount(accountName: string): void {
if (!accountName) return;
const newElement = new OverlayBrowsing(null, accountName, null);
const newElement = new OverlayBrowsing('account', null, accountName, null);
this.loadElement(newElement);
}
@ -132,14 +132,28 @@ export class StreamOverlayComponent implements OnInit, OnDestroy {
const selectedAccount = this.toolsService.getSelectedAccounts()[0];
const hashTagElement = new StreamElement(StreamTypeEnum.tag, hashtag, selectedAccount.id, hashtag, null, null, selectedAccount.instance);
const newElement = new OverlayBrowsing(hashTagElement, null, null);
const newElement = new OverlayBrowsing('hashtag', hashTagElement, null, null);
this.loadElement(newElement);
}
browseThread(openThread: OpenThreadEvent): any {
if (!openThread) return;
const newElement = new OverlayBrowsing(null, null, openThread);
const newElement = new OverlayBrowsing('thread', null, null, openThread);
this.loadElement(newElement);
}
browseFollows(accountName: string): void {
if (!accountName) return;
const newElement = new OverlayBrowsing('follows', null, accountName, null);
this.loadElement(newElement);
}
browseFollowers(accountName: string): void {
if (!accountName) return;
const newElement = new OverlayBrowsing('followers', null, accountName, null);
this.loadElement(newElement);
}
@ -167,19 +181,10 @@ class OverlayBrowsing {
goToTopEventEmitter = new EventEmitter();
constructor(
public readonly type: 'hashtag' | 'account' | 'thread' | 'follows' | 'followers',
public readonly hashtag: StreamElement,
public readonly account: string,
public readonly thread: OpenThreadEvent) {
if (hashtag) {
this.type = 'hashtag';
} else if (account) {
this.type = 'account';
} else if (thread) {
this.type = 'thread';
} else {
throw Error('NotImplemented');
}
}
show(): any {
@ -198,5 +203,4 @@ class OverlayBrowsing {
}
isVisible: boolean;
type: 'hashtag' | 'account' | 'thread';
}

View File

@ -0,0 +1,9 @@
<div class="follow flexcroll" #accountslist (scroll)="onScroll()" >
<div class="accounts" *ngIf="accounts.length > 0">
<app-account class="account" *ngFor="let account of accounts"
[account]="account"
(accountSelected)="browseAccount($event)"></app-account>
</div>
<div *ngIf="accounts.length === 0 && !isLoading" class="follow__empty"> There is nothing here! </div>
<app-waiting-animation *ngIf="isLoading" class="waiting-icon"></app-waiting-animation>
</div>

View File

@ -0,0 +1,20 @@
@import "variables";
@import "commons";
.follow {
height: calc(100%);
width: calc(100%);
overflow: auto;
position: relative;
&__empty {
padding: 20px 0 0 0;
text-align: center;
}
}
.account {
display: block;
margin: 0 1px;
}

View File

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

View File

@ -0,0 +1,195 @@
import { Component, OnInit, Input, EventEmitter, Output, OnDestroy, ViewChild, ElementRef } from '@angular/core';
import { Subscription, Observable } from 'rxjs';
import { HttpErrorResponse } from '@angular/common/http';
import { MastodonWrapperService } from '../../../services/mastodon-wrapper.service';
import { ToolsService } from '../../../services/tools.service';
import { Account } from "../../../services/models/mastodon.interfaces";
import { NotificationService } from '../../../services/notification.service';
import { FollowingResult } from '../../../services/mastodon.service';
@Component({
selector: 'app-user-follows',
templateUrl: './user-follows.component.html',
styleUrls: ['./user-follows.component.scss']
})
export class UserFollowsComponent implements OnInit, OnDestroy {
private _type: 'follows' | 'followers';
private _currentAccount: string;
private maxId: string;
isLoading: boolean = true;
accounts: Account[] = [];
@Input('type')
set setType(type: 'follows' | 'followers') {
this._type = type;
this.load(this._type, this._currentAccount);
}
get setType(): 'follows' | 'followers' {
return this._type;
}
@Input('currentAccount')
set currentAccount(accountName: string) {
this._currentAccount = accountName;
this.load(this._type, this._currentAccount);
}
get currentAccount(): string {
return this._currentAccount;
}
@Input() refreshEventEmitter: EventEmitter<any>;
@Input() goToTopEventEmitter: EventEmitter<any>;
@Output() browseAccountEvent = new EventEmitter<string>();
@ViewChild('accountslist') public accountslist: ElementRef;
private refreshSubscription: Subscription;
private goToTopSubscription: Subscription;
// private accountSub: Subscription;
// private accounts$: Observable<AccountInfo[]>;
constructor(
private readonly notificationService: NotificationService,
private readonly toolsService: ToolsService,
private readonly mastodonService: MastodonWrapperService) {
// this.accounts$ = this.store.select(state => state.registeredaccounts.accounts);
}
ngOnInit() {
if (this.refreshEventEmitter) {
this.refreshSubscription = this.refreshEventEmitter.subscribe(() => {
this.refresh();
})
}
if (this.goToTopEventEmitter) {
this.goToTopSubscription = this.goToTopEventEmitter.subscribe(() => {
this.goToTop();
})
}
}
ngOnDestroy(): void {
if (this.refreshSubscription) this.refreshSubscription.unsubscribe();
if (this.goToTopSubscription) this.goToTopSubscription.unsubscribe();
// if (this.accountSub) this.accountSub.unsubscribe();
}
private load(type: 'follows' | 'followers', accountName: string) {
if (type && accountName) {
this.accounts = [];
this.isLoading = true;
let currentAccount = this.toolsService.getSelectedAccounts()[0];
this.toolsService.findAccount(currentAccount, accountName)
.then((acc: Account) => {
if (type === 'followers') {
return this.mastodonService.getFollowers(currentAccount, acc.id, null, null);
} else if (type === 'follows') {
return this.mastodonService.getFollowing(currentAccount, acc.id, null, null);
} else {
throw Error('not implemented');
}
})
.then((result: FollowingResult) => {
console.warn(result);
this.maxId = result.maxId;
this.accounts = result.follows;
})
.catch(err => {
this.notificationService.notifyHttpError(err, currentAccount);
})
.then(() => {
this.isLoading = false;
});
}
}
private scrolledToBottom() {
if (this.isLoading || this.maxReached || this.scrolledErrorOccured || this.accounts.length === 0) return;
this.isLoading = true;
this.isProcessingInfiniteScroll = true;
let currentAccount = this.toolsService.getSelectedAccounts()[0];
this.toolsService.findAccount(currentAccount, this._currentAccount)
.then((acc: Account) => {
if (this._type === 'followers') {
return this.mastodonService.getFollowers(currentAccount, acc.id, this.maxId, null);
} else if (this._type === 'follows') {
return this.mastodonService.getFollowing(currentAccount, acc.id, this.maxId, null);
} else {
throw Error('not implemented');
}
})
.then((result: FollowingResult) => {
if(!result) return;
let accounts = result.follows;
if (!accounts || accounts.length === 0 || this.maxReached) {
this.maxReached = true;
return;
}
for (let a of accounts) {
this.accounts.push(a);
}
this.maxId = result.maxId;
if(!this.maxId) {
this.maxReached = true;
}
})
.catch((err: HttpErrorResponse) => {
this.scrolledErrorOccured = true;
setTimeout(() => {
this.scrolledErrorOccured = false;
}, 5000);
this.notificationService.notifyHttpError(err, currentAccount);
})
.then(() => {
this.isLoading = false;
this.isProcessingInfiniteScroll = false;
});
}
refresh(): any {
this.load(this._type, this._currentAccount);
}
goToTop(): any {
const stream = this.accountslist.nativeElement as HTMLElement;
setTimeout(() => {
stream.scrollTo({
top: 0,
behavior: 'smooth'
});
}, 0);
}
browseAccount(account: Account) {
let acc = this.toolsService.getAccountFullHandle(account);
this.browseAccountEvent.next(acc);
}
onScroll() {
var element = this.accountslist.nativeElement as HTMLElement;
const atBottom = element.scrollHeight <= element.clientHeight + element.scrollTop + 1000;
const atTop = element.scrollTop === 0;
if (atBottom) {
this.scrolledToBottom();
}
}
private scrolledErrorOccured = false;
private maxReached = false;
private isProcessingInfiniteScroll = false;
}

View File

@ -121,6 +121,11 @@
target="_blank" title="{{displayedAccount.acct}}">@{{displayedAccount.acct}}</a></h2>
</div>
<div class="profile-follows">
<a href class="profile-follows__link" title="show following" (click)="browseFollows()" >Following</a>
<a href class="profile-follows__link" title="show followers" (click)="browseFollowers()">Followers</a>
</div>
<!-- <div class="profile__extra-info">
<div class="profile__extra-info__section">
<a href class="profile__extra-info__links"

View File

@ -15,7 +15,7 @@ $floating-header-height: 60px;
position: relative;
}
.profile {
.profile {
// overflow: auto;
height: calc(100%);
// width: $stream-column-width;
@ -175,7 +175,8 @@ $floating-header-height: 60px;
display: inline-block;
transition: all .2s;
color: white;
&:hover{
&:hover {
color: rgb(216, 216, 216);
}
}
@ -188,7 +189,7 @@ $floating-header-height: 60px;
color: #5fbcff;
color: #85ccff;
&:hover{
&:hover {
color: #85ccff;
color: #38abff;
}
@ -212,7 +213,7 @@ $floating-header-height: 60px;
font-size: 12px;
// max-width: 150px;
width: 265px;
//outline: 1px solid greenyellow;
&--data {
@ -221,7 +222,7 @@ $floating-header-height: 60px;
padding: 4px 10px;
border-radius: 4px;
text-align: center;
margin: 0 2px 2px 0;
margin: 0 2px 2px 0;
}
}
@ -252,7 +253,7 @@ $floating-header-height: 60px;
overflow: hidden;
margin: 0;
&:not(:last-child){
&:not(:last-child) {
margin-bottom: 3px;
}
}
@ -271,6 +272,27 @@ $floating-header-height: 60px;
}
}
&-follows {
width: calc(100%);
font-size: 13px;
border-bottom: 1px solid #0f111a;;
&__link {
color: white;
width: calc(50%);
padding: 5px;
text-align: center;
display: inline-block;
background-color: #1a1f2e;
transition: all .2s;
&:hover {
text-decoration: none;
background-color: #131722;
}
}
}
&-description {
padding: 9px 10px 15px 10px;
font-size: 13px;
@ -360,7 +382,7 @@ $floating-header-height: 60px;
}
&__status-switching-section {
height: calc(100vh - 35px - #{$floating-header-height} - #{$stream-header-height} - #{$stream-selector-height});
height: calc(100vh - 35px - #{$floating-header-height} - #{$stream-header-height} - #{$stream-selector-height});
}
&-no-toots {

View File

@ -418,4 +418,19 @@ export class UserProfileComponent extends BrowseBase {
this.navigationService.openMedia(openMediaEvent);
return false;
}
@Output() browseFollowsEvent = new EventEmitter<string>();
@Output() browseFollowersEvent = new EventEmitter<string>();
browseFollows(): boolean {
let accountName = this.toolsService.getAccountFullHandle(this.displayedAccount);
this.browseFollowsEvent.next(accountName);
return false;
}
browseFollowers(): boolean {
let accountName = this.toolsService.getAccountFullHandle(this.displayedAccount);
this.browseFollowersEvent.next(accountName);
return false;
}
}

View File

@ -4,14 +4,14 @@ 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, BookmarkResult } from './mastodon.service';
import { FavoriteResult, VisibilityEnum, PollParameters, MastodonService, BookmarkResult, FollowingResult } from './mastodon.service';
import { AuthService } from './auth.service';
import { AppInfo, RegisteredAppsStateModel } from '../states/registered-apps.state';
@Injectable({
providedIn: 'root'
})
export class MastodonWrapperService {
export class MastodonWrapperService {
private refreshingToken: { [id: string]: Promise<AccountInfo> } = {};
constructor(
@ -391,4 +391,18 @@ export class MastodonWrapperService {
return this.mastodonService.deleteScheduledStatus(refreshedAccount, statusId);
});
}
getFollowing(account: AccountInfo, accountId: number, maxId: string, sinceId: string, limit: number = 40): Promise<FollowingResult> {
return this.refreshAccountIfNeeded(account)
.then((refreshedAccount: AccountInfo) => {
return this.mastodonService.getFollowing(refreshedAccount, accountId, maxId, sinceId, limit);
});
}
getFollowers(account: AccountInfo, accountId: number, maxId: string, sinceId: string, limit: number = 40): Promise<FollowingResult> {
return this.refreshAccountIfNeeded(account)
.then((refreshedAccount: AccountInfo) => {
return this.mastodonService.getFollowers(refreshedAccount, accountId, maxId, sinceId, limit);
});
}
}

View File

@ -468,6 +468,49 @@ export class MastodonService {
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.delete<ScheduledStatus>(route, { headers: headers }).toPromise();
}
getFollowers(account: AccountInfo, targetAccountId: number, maxId: string, sinceId: string, limit: number = 40): Promise<FollowingResult> {
const route = `https://${account.instance}${this.apiRoutes.getFollowers}`.replace('{0}', targetAccountId.toString());
let params = `?limit=${limit}`;
if (maxId) params += `&max_id=${maxId}`;
if (sinceId) params += `&since_id=${sinceId}`;
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.get<Account[]>(route + params, { headers: headers, observe: "response" }).toPromise()
.then((res: HttpResponse<Account[]>) => {
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 FollowingResult(lastId, res.body)
});
}
getFollowing(account: AccountInfo, targetAccountId: number, maxId: string, sinceId: string, limit: number = 40): Promise<FollowingResult> {
const route = `https://${account.instance}${this.apiRoutes.getFollowing}`.replace('{0}', targetAccountId.toString());
let params = `?limit=${limit}`;
if (maxId) params += `&max_id=${maxId}`;
if (sinceId) params += `&since_id=${sinceId}`;
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.get<Account[]>(route + params, { headers: headers, observe: "response" }).toPromise()
.then((res: HttpResponse<Account[]>) => {
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 FollowingResult(lastId, res.body)
});
}
}
export enum VisibilityEnum {
@ -506,4 +549,10 @@ export class BookmarkResult {
constructor(
public max_id: string,
public bookmarked: Status[]) { }
}
export class FollowingResult {
constructor(
public maxId: string,
public follows: Account[]) { }
}

View File

@ -73,4 +73,6 @@ export class ApiRoutes {
bookmarkingStatus = '/api/v1/statuses/{0}/bookmark';
unbookmarkingStatus = '/api/v1/statuses/{0}/unbookmark';
getBookmarks = '/api/v1/bookmarks';
getFollowers = '/api/v1/accounts/{0}/followers';
getFollowing = '/api/v1/accounts/{0}/following';
}