Merge pull request #22 from NicolasConstant/feature_open-user-profile

Feature open user profile
This commit is contained in:
Nicolas Constant 2018-11-02 00:24:16 -04:00 committed by GitHub
commit 815213006d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 749 additions and 219 deletions

View File

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

View File

@ -46,7 +46,7 @@ export class SearchComponent implements OnInit {
//First candid implementation
if (enabledAccounts.length > 0) {
const candid_oneAccount = enabledAccounts[0];
this.mastodonService.search(candid_oneAccount, data)
this.mastodonService.search(candid_oneAccount, data, true)
.then((results: Results) => {
if (results) {
console.warn(results);

View File

@ -1,15 +1,24 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, Output, EventEmitter, Input } from '@angular/core';
@Component({
selector: 'app-hashtag',
templateUrl: './hashtag.component.html',
styleUrls: ['./hashtag.component.scss']
selector: 'app-hashtag',
templateUrl: './hashtag.component.html',
styleUrls: ['./hashtag.component.scss']
})
export class HashtagComponent implements OnInit {
hashtag: string;
constructor() { }
@Output() browseAccount = new EventEmitter<string>();
@Output() browseHashtag = new EventEmitter<string>();
ngOnInit() {
}
@Input('currentHashtag')
set currentAccount(hashtag: string) {
this.hashtag = hashtag;
}
constructor() { }
ngOnInit() {
}
}

View File

@ -1 +1 @@
<div #content class="content" innerHTML="{{processedText}}" (click)="selectText()"></div>
<div #content class="content" [class.selectable]="textIsSelectable" innerHTML="{{processedText}}" (click)="selectText()"></div>

View File

@ -1,6 +1,6 @@
@import "variables";
.content {
.selectable {
cursor: pointer;
}

View File

@ -2,6 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { DatabindedTextComponent } from './databinded-text.component';
import { By } from '@angular/platform-browser';
import { isGeneratedFile } from '@angular/compiler/src/aot/util';
describe('DatabindedTextComponent', () => {
let component: DatabindedTextComponent;
@ -35,7 +36,7 @@ describe('DatabindedTextComponent', () => {
const url = 'https://test.social/tags/programmers';
const sample = `<p>bla1 <a href="${url}" class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>${hashtag}</span></a> bla2</p>`;
component.text = sample;
expect(component.processedText).toContain('<a href class="programmers">#programmers</a>');
expect(component.processedText).toContain('<a href class="hashtag-programmers">#programmers</a>');
expect(component.processedText).toContain('bla1');
expect(component.processedText).toContain('bla2');
});
@ -59,6 +60,20 @@ describe('DatabindedTextComponent', () => {
expect(component.processedText).toContain('bla2');
});
it('should parse link - dual section', () => {
const sample = `<p>Test.<br><a href="https://peertube.fr/videos/watch/69bb6e90-ec0f-49a3-9e28-41792f4a7c5f" rel="nofollow noopener" target="_blank"><span class="invisible">https://</span><span class="ellipsis">peertube.fr/videos/watch/69bb6</span><span class="invisible">e90-ec0f-49a3-9e28-41792f4a7c5f</span></a></p>`;
component.text = sample;
expect(component.processedText).toContain('<p>Test.<br><a href class="link-httpspeertubefrvideoswatch69bb6e90ec0f49a39e2841792f4a7c5f" title="open link">peertube.fr/videos/watch/69bb6</a></p>');
});
it('should parse link with special character', () => {
const sample = `<p>Magnitude: 2.5 Depth: 3.4 km<br>Details: 2018/09/27 06:50:17 34.968N 120.685W<br>Location: 10 km (6 mi) W of Guadalupe, CA<br>Map: <a href="https://www.google.com/maps/place/34°58'4%20N+120°41'6%20W/@34.968,-120.685,10z" rel="noopener" target="_blank" class="status-link" title="https://www.google.com/maps/place/34%C2%B058'4%20N+120%C2%B041'6%20W/@34.968,-120.685,10z"><span class="invisible">https://www.</span><span class="ellipsis">google.com/maps/place/34°58'4%</span><span class="invisible">20N+120°41'6%20W/@34.968,-120.685,10z</span></a><br><a href="https://mastodon.cloud/tags/earthquake" class="mention hashtag status-link" rel="noopener" target="_blank">#<span>EarthQuake</span></a> <a href="https://mastodon.cloud/tags/quake" class="mention hashtag status-link" rel="noopener" target="_blank">#<span>Quake</span></a> <a href="https://mastodon.cloud/tags/california" class="mention hashtag status-link" rel="noopener" target="_blank">#<span>California</span></a></p>`;
component.text = sample;
expect(component.processedText).toContain('<a href class="link-httpswwwgooglecommapsplace3458420N12041620W3496812068510z" title="open link">google.com/maps/place/34°58\'4%</a>');
});
it('should parse combined hashtag, mention and link', () => {
const hashtag = 'programmers';
const hashtagUrl = 'https://test.social/tags/programmers';
@ -67,7 +82,7 @@ describe('DatabindedTextComponent', () => {
const linkUrl = 'mydomain.co/test';
const sample = `<p>bla1 <a href="${hashtagUrl}" class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>${hashtag}</span></a> bla2 <span class="h-card"><a href="${mentionUrl}" class="u-url mention" rel="nofollow noopener" target="_blank">@<span>${mention}</span></a></span> bla3 <a href="https://${linkUrl}" rel="nofollow noopener" target="_blank"><span class="invisible">https://</span><span class="">${linkUrl}</span><span class="invisible"></span></a> bla4</p>`;
component.text = sample;
expect(component.processedText).toContain('<a href class="programmers">#programmers</a>');
expect(component.processedText).toContain('<a href class="hashtag-programmers">#programmers</a>');
expect(component.processedText).toContain('<a href class="account--sengi_app-mastodon-social" title="@sengi_app@mastodon.social">@sengi_app</a>');
expect(component.processedText).toContain('<a href class="link-httpsmydomaincotest" title="open link">mydomain.co/test</a>');
expect(component.processedText).toContain('bla1');
@ -75,4 +90,27 @@ describe('DatabindedTextComponent', () => {
expect(component.processedText).toContain('bla3');
expect(component.processedText).toContain('bla4');
});
it('should parse link - GNU social in Mastodon', () => {
const sample = `bla1 <a href="https://www.lemonde.fr/planete.html?xtor=RSS-3208" rel="nofollow noopener" class="" target="_blank">https://social.bitcast.info/url/819438</a>`;
component.text = sample;
expect(component.processedText).toContain('<a href class="link-httpswwwlemondefrplanetehtmlxtorRSS3208" title="open link">https://social.bitcast.info/url/819438</a>');
expect(component.processedText).toContain('bla1');
});
it('should parse mention - Pleroma in Mastodon', () => {
const sample = `<div>bla1 <br> @<a href="https://instance.club/user/1" class="h-card mention status-link" rel="noopener" target="_blank" title="https://instance.club/user/1">user</a>&nbsp;</div>`;
component.text = sample;
expect(component.processedText).toContain('<a href class="account--user-instance-club" title="@user@instance.club">@user</a>');
expect(component.processedText).toContain('bla1');
})
it('should parse mention - Pleroma in Mastodon - 2', () => {
const sample = `<div><span><a class="mention status-link" href="https://pleroma.site/users/kaniini" rel="noopener" target="_blank" title="kaniini@pleroma.site">@<span>kaniini</span></a></span> <span><a class="mention status-link" href="https://mastodon.social/@Gargron" rel="noopener" target="_blank" title="Gargron@mastodon.social">@<span>Gargron</span></a></span> bla1?</div>`;
component.text = sample;
expect(component.processedText).toContain('<div><span><a href class="account--kaniini-pleroma-site" title="@kaniini@pleroma.site">@kaniini</a> <span><a href class="account--Gargron-mastodon-social" title="@Gargron@mastodon.social">@Gargron</a> bla1?</div>');
});
});

View File

@ -18,30 +18,44 @@ export class DatabindedTextComponent implements OnInit {
@Output() hashtagSelected = new EventEmitter<string>();
@Output() textSelected = new EventEmitter();
@Input() textIsSelectable: boolean = true;
@Input('text')
set text(value: string) {
this.processedText = '';
let linksSections = value.split('<a ');
for (let section of linksSections) {
console.log(section);
if (!section.includes('href')) {
this.processedText += section;
continue;
}
if (section.includes('class="mention hashtag"') || section.includes('target="_blank">#')) {
console.log('process hashtag');
this.processHashtag(section);
} else if (section.includes('class="u-url mention"') || section.includes('class="mention"')) {
console.log('process mention');
this.processUser(section);
try {
this.processHashtag(section);
}
catch (err) {
console.warn('process hashtag');
console.warn(value);
}
} else if (section.includes('class="u-url mention"') || section.includes('class="mention"') || section.includes('class="mention status-link"') || section.includes('class="h-card mention')) {
try {
this.processUser(section);
}
catch (err) {
console.warn('process mention');
console.warn(value);
}
} else {
console.log('process link');
console.log(section);
this.processLink(section);
try {
this.processLink(section);
}
catch (err) {
console.warn('process link');
console.warn(value);
}
}
}
}
@ -50,27 +64,41 @@ export class DatabindedTextComponent implements OnInit {
let extractedLinkAndNext = section.split('</a>');
let extractedHashtag = extractedLinkAndNext[0].split('#')[1].replace('<span>', '').replace('</span>', '');
this.processedText += ` <a href class="${extractedHashtag}">#${extractedHashtag}</a>`;
let classname = this.getClassNameForHastag(extractedHashtag);
this.processedText += ` <a href class="${classname}">#${extractedHashtag}</a>`;
if (extractedLinkAndNext[1]) this.processedText += extractedLinkAndNext[1];
this.hashtags.push(extractedHashtag);
}
private processUser(section: string) {
let mentionClass = 'class="mention"';
if (section.includes('class="u-url mention"'))
mentionClass = 'class="u-url mention"';
let extractedAccountAndNext: string[];
let extractedAccountName: string;
let extractedAccountAndNext = section.split('</a></span>');
let extractedAccountName = extractedAccountAndNext[0].split('@<span>')[1].replace('<span>', '').replace('</span>', '');
if (!section.includes('@<span>')) { //GNU social
extractedAccountAndNext = section.split('</a>');
extractedAccountName = extractedAccountAndNext[0].split('>')[1];
} else {
extractedAccountAndNext = section.split('</a></span>');
extractedAccountName = extractedAccountAndNext[0].split('@<span>')[1].replace('<span>', '').replace('</span>', '');
}
let extractedAccountLink = extractedAccountAndNext[0].split('href="https://')[1].split('"')[0].replace(' ', '').replace('@', '').split('/');
let extractedAccount = `@${extractedAccountLink[1]}@${extractedAccountLink[0]}`;
let domain = extractedAccountLink[0];
//let username = extractedAccountLink[extractedAccountLink.length - 1];
let extractedAccount = `@${extractedAccountName}@${domain}`;
let classname = this.getClassNameForAccount(extractedAccount);
this.processedText += ` <a href class="${classname}" title="${extractedAccount}">@${extractedAccountName}</a>`;
this.processedText += `<a href class="${classname}" title="${extractedAccount}">@${extractedAccountName}</a>`;
if (extractedAccountAndNext[1])
this.processedText += extractedAccountAndNext[1];
//GNU Social clean up
if(this.processedText.includes('@<a'))
this.processedText = this.processedText.replace('@<a', '<a');
if (extractedAccountAndNext[1]) this.processedText += extractedAccountAndNext[1];
this.accounts.push(extractedAccount);
}
@ -78,20 +106,17 @@ export class DatabindedTextComponent implements OnInit {
let extractedLinkAndNext = section.split('</a>')
let extractedUrl = extractedLinkAndNext[0].split('"')[1];
console.warn(extractedLinkAndNext[0]);
console.warn(extractedLinkAndNext[0].split('<span class="ellipsis">'));
let extractedName = '';
try {
extractedName = extractedLinkAndNext[0].split('<span class="ellipsis">')[1].split('</span>')[0];
} catch (err){
} catch (err) {
try {
extractedName = extractedLinkAndNext[0].split('<span class="invisible">https://</span><span class="">')[1].split('</span>')[0];
extractedName = extractedLinkAndNext[0].split('<span class="invisible">https://</span><span class="">')[1].split('</span>')[0];
}
catch(err){
extractedName = extractedLinkAndNext[0].split('rel="nofollow noopener" target="_blank">')[1].split('</span>')[0];
catch (err) {
extractedName = extractedLinkAndNext[0].split(' target="_blank">')[1].split('</span>')[0];
}
}
}
this.links.push(extractedUrl);
let classname = this.getClassNameForLink(extractedUrl);
@ -109,7 +134,8 @@ export class DatabindedTextComponent implements OnInit {
ngAfterViewInit() {
for (const hashtag of this.hashtags) {
let el = this.contentElement.nativeElement.querySelector(`.${hashtag}`);
let classname = this.getClassNameForHastag(hashtag);
let el = this.contentElement.nativeElement.querySelector(`.${classname}`);
this.renderer.listen(el, 'click', (event) => {
event.preventDefault();
@ -146,6 +172,11 @@ export class DatabindedTextComponent implements OnInit {
}
}
private getClassNameForHastag(value: string): string {
let res = value.replace(/[.,\/#?!@$%+\^&\*;:{}=\-_`~()]/g, "");
return `hashtag-${res}`;
}
private getClassNameForAccount(value: string): string {
let res = value;
while (res.includes('.')) res = res.replace('.', '-');
@ -154,22 +185,19 @@ export class DatabindedTextComponent implements OnInit {
}
private getClassNameForLink(value: string): string {
let res = value.replace(/[.,\/#?!@$%\^&\*;:{}=\-_`~()]/g, "");
let res = value.replace(/[.,\/#?!@°$%+\'\^&\*;:{}=\-_`~()]/g, "");
return `link-${res}`;
}
private selectAccount(account: string) {
console.warn(`select ${account}`);
this.accountSelected.next(account);
}
private selectHashtag(hashtag: string) {
console.warn(`select ${hashtag}`);
this.hashtagSelected.next(hashtag);
}
selectText() {
console.warn(`selectText`);
this.textSelected.next();
}

View File

@ -1,9 +1,10 @@
import { Component, OnInit, Input, Output, EventEmitter, ElementRef, ViewChild } from '@angular/core';
import { Store } from '@ngxs/store';
// import { Store } from '@ngxs/store';
import { MastodonService, VisibilityEnum } from '../../../../services/mastodon.service';
import { AccountInfo } from '../../../../states/accounts.state';
// import { AccountInfo } from '../../../../states/accounts.state';
import { StatusWrapper } from '../../stream.component';
import { Status } from '../../../../services/models/mastodon.interfaces';
import { ToolsService } from '../../../../services/tools.service';
@Component({
selector: 'app-reply-to-status',
@ -22,7 +23,8 @@ export class ReplyToStatusComponent implements OnInit {
privacyList: string[] = ['Public', 'Unlisted', 'Follows-only', 'DM'];
constructor(
private readonly store: Store,
// private readonly store: Store,
private readonly toolsService: ToolsService,
private readonly mastodonService: MastodonService) { }
ngOnInit() {
@ -39,8 +41,7 @@ export class ReplyToStatusComponent implements OnInit {
}
onSubmit(): boolean {
const accounts = this.getRegisteredAccounts();
const selectedAccounts = accounts.filter(x => x.isSelected);
const selectedAccounts = this.toolsService.getSelectedAccounts();
let visibility: VisibilityEnum = VisibilityEnum.Unknown;
switch (this.selectedPrivacy) {
@ -72,10 +73,10 @@ export class ReplyToStatusComponent implements OnInit {
return false;
}
private getRegisteredAccounts(): AccountInfo[] {
var regAccounts = <AccountInfo[]>this.store.snapshot().registeredaccounts.accounts;
return regAccounts;
}
// private getRegisteredAccounts(): AccountInfo[] {
// var regAccounts = <AccountInfo[]>this.store.snapshot().registeredaccounts.accounts;
// return regAccounts;
// }
onCtrlEnter(): boolean {
this.onSubmit();

View File

@ -13,10 +13,10 @@
</span>
</a>
<div class="status__created-at" title="{{ displayedStatus.created_at | date: 'full' }}">{{
getCompactRelativeTime(status.created_at) }}</div>
status.created_at | timeAgo | async }}</div>
<!-- <div #content class="status__content" innerHTML="{{displayedStatus.content}}"></div> -->
<app-databinded-text class="status__content" [text]="displayedStatus.content"></app-databinded-text>
<app-databinded-text class="status__content" [text]="displayedStatus.content" (accountSelected)="accountSelected($event)" (hashtagSelected)="hashtagSelected($event)" (textSelected)="textSelected()"></app-databinded-text>
<app-attachements *ngIf="hasAttachments" class="attachments" [attachments]="displayedStatus.media_attachments"></app-attachements>

View File

@ -17,12 +17,10 @@ export class StatusComponent implements OnInit {
hasAttachments: boolean;
replyingToStatus: boolean;
@Output() browseAccount = new EventEmitter<Account>();
@Output() browseAccount = new EventEmitter<string>();
@Output() browseHashtag = new EventEmitter<string>();
@Output() browseThread = new EventEmitter<string>();
private _statusWrapper: StatusWrapper;
status: Status;
@Input('statusWrapper')
@ -74,27 +72,31 @@ export class StatusComponent implements OnInit {
// }
openAccount(account: Account): boolean {
this.browseAccount.next(account);
let accountName = account.acct;
if(!accountName.includes('@'))
accountName += `@${account.url.replace('https://', '').split('/')[0]}`;
this.browseAccount.next(accountName);
return false;
}
getCompactRelativeTime(d: string): string {
const date = (new Date(d)).getTime();
const now = Date.now();
const timeDelta = (now - date) / (1000);
// getCompactRelativeTime(d: string): string {
// const date = (new Date(d)).getTime();
// const now = Date.now();
// const timeDelta = (now - date) / (1000);
if (timeDelta < 60) {
return `${timeDelta | 0}s`;
} else if (timeDelta < 60 * 60) {
return `${timeDelta / 60 | 0}m`;
} else if (timeDelta < 60 * 60 * 24) {
return `${timeDelta / (60 * 60) | 0}h`;
} else if (timeDelta < 60 * 60 * 24 * 31) {
return `${timeDelta / (60 * 60 * 24) | 0}d`;
}
// if (timeDelta < 60) {
// return `${timeDelta | 0}s`;
// } else if (timeDelta < 60 * 60) {
// return `${timeDelta / 60 | 0}m`;
// } else if (timeDelta < 60 * 60 * 24) {
// return `${timeDelta / (60 * 60) | 0}h`;
// } else if (timeDelta < 60 * 60 * 24 * 31) {
// return `${timeDelta / (60 * 60 * 24) | 0}d`;
// }
return formatDate(date, 'MM/dd', this.locale);
}
// return formatDate(date, 'MM/dd', this.locale);
// }
openReply(): boolean {
this.replyingToStatus = !this.replyingToStatus;
@ -107,8 +109,22 @@ export class StatusComponent implements OnInit {
return false;
}
test(): boolean {
console.warn('heeeeyaaa!');
return false;
// test(): boolean {
// console.warn('heeeeyaaa!');
// return false;
// }
accountSelected(accountName: string): void {
console.warn(`status comp: accountSelected ${accountName}`);
this.browseAccount.next(accountName);
}
hashtagSelected(hashtag: string): void {
console.warn(`status comp: hashtagSelected ${hashtag}`);
this.browseHashtag.next(hashtag);
}
textSelected(): void {
console.warn(`status comp: textSelected`);
}
}

View File

@ -1,13 +1,15 @@
<div class="stream-overlay">
<div class="stream-overlay__header">
<a href class="overlay-close" (click)="close()">CLOSE</a>
<a href class="overlay-previous">PREV</a>
<a href class="overlay-next">NEXT</a>
<a href class="overlay-close" (click)="close()">CLOSE</a>
<a href class="overlay-previous" (click)="previous()">PREV</a>
<a href class="overlay-refresh" *ngIf="canRefresh" (click)="refresh()">REFRESH</a>
<a href class="overlay-next" *ngIf="canGoForward" (click)="next()">NEXT</a>
</div>
<!-- <div class="stream-overlay__title">
Account
</div> -->
<app-user-profile *ngIf="browseAccount" [currentAccount]="browseAccount"></app-user-profile>
<app-user-profile *ngIf="accountName" [currentAccount]="accountName" (browseAccount)="accountSelected($event)" (browseHashtag)="hashtagSelected($event)"></app-user-profile>
<app-hashtag *ngIf="browseHashtag"></app-hashtag>
<app-thread *ngIf="browseThread"></app-thread>
</div>

View File

@ -1,14 +1,11 @@
@import "variables";
@import "commons";
.stream-overlay {
// position: absolute;
// z-index: 50;
width: $stream-column-width;
height: calc(100%);
background-color: $column-color;
// margin: 0 0 0 $stream-column-separator;
background-color: $column-color; // margin: 0 0 0 $stream-column-separator;
// outline: 1px red solid;
// float: left;
&__header {
@ -16,14 +13,12 @@
height: 30px;
background-color: $column-header-background-color;
padding: 6px 10px 0 10px;
& a {
& a {
color: whitesmoke;
font-size: 0.8em;
font-weight: normal;
margin: 0;
font-weight: normal; // margin: 0;
}
}
&__title {
width: calc(100%);
height: 30px;
@ -34,18 +29,31 @@
}
}
.overlay-previous {
display: block;
float: left;
.overlay {
margin: 0;
&-previous {
display: block;
float: left;
}
&-refresh {
display: block;
float: left;
margin-left: 65px;
}
&-next {
display: block;
float: right;
padding-right: 20px;
}
&-close {
display: block;
float: right;
}
}
.overlay-next {
display: block;
float: right;
padding-right: 20px;
}
.overlay-close {
display: block;
float: right;
.not-active {
pointer-events: none;
cursor: default;
text-decoration: none;
color: gray !important;
}

View File

@ -1,5 +1,7 @@
import { Component, OnInit, Output, EventEmitter, Input } from '@angular/core';
import { Account } from "../../../services/models/mastodon.interfaces";
import { Account, Results } from "../../../services/models/mastodon.interfaces";
import { MastodonService } from '../../../services/mastodon.service';
import { ToolsService } from '../../../services/tools.service';
@Component({
selector: 'app-stream-overlay',
@ -7,34 +9,34 @@ import { Account } from "../../../services/models/mastodon.interfaces";
styleUrls: ['./stream-overlay.component.scss']
})
export class StreamOverlayComponent implements OnInit {
private account: Account;
private thread: string;
private hashtag: string;
private previousElements: OverlayBrowsing[] = [];
private nextElements: OverlayBrowsing[] = [];
private currentElement: OverlayBrowsing;
canRefresh: boolean;
canGoForward: boolean;
accountName: string;
thread: string;
hashtag: string;
@Output() closeOverlay = new EventEmitter();
@Input('browseAccount')
set browseAccount(account: Account) {
this.account = account;
}
get browseAccount(): Account{
return this.account;
@Input('browseAccount')
set browseAccount(accountName: string) {
this.accountSelected(accountName);
// this.accountName = accountName;
}
@Input('browseThread')
@Input('browseThread')
set browseThread(thread: string) {
this.thread = thread;
}
get browseThread(): string{
return this.thread;
// this.thread = thread;
}
@Input('browseHashtag')
@Input('browseHashtag')
set browseHashtag(hashtag: string) {
this.hashtag = hashtag;
}
get browseHashtag(): string{
return this.hashtag;
this.hashtagSelected(hashtag);
// this.hashtag = hashtag;
}
constructor() { }
@ -47,4 +49,107 @@ export class StreamOverlayComponent implements OnInit {
return false;
}
next(): boolean {
console.log('next');
if (this.nextElements.length === 0) {
return false;
}
if (this.currentElement) {
this.previousElements.push(this.currentElement);
}
const nextElement = this.nextElements.pop();
this.loadElement(nextElement);
return false;
}
previous(): boolean {
console.log('previous');
if (this.previousElements.length === 0) {
this.closeOverlay.next();
return false;
}
if (this.currentElement) {
this.nextElements.push(this.currentElement);
}
const previousElement = this.previousElements.pop();
this.loadElement(previousElement);
this.canGoForward = true;
return false;
}
refresh(): boolean {
console.log('refresh');
return false;
}
accountSelected(accountName: string): void {
if(!accountName) return;
console.log('accountSelected');
this.nextElements.length = 0;
if (this.currentElement) {
this.previousElements.push(this.currentElement);
}
const newElement = new OverlayBrowsing(null, accountName, null);
this.loadElement(newElement);
this.canGoForward = false;
}
hashtagSelected(hashtag: string): void {
if(!hashtag) return;
console.log('hashtagSelected');
this.nextElements.length = 0;
if (this.currentElement) {
this.previousElements.push(this.currentElement);
}
const newElement = new OverlayBrowsing(hashtag, null, null);
this.loadElement(newElement);
this.canGoForward = false;
}
private loadElement(element: OverlayBrowsing) {
this.currentElement = element;
this.accountName = this.currentElement.account;
this.hashtag = this.currentElement.hashtag;
this.thread = this.currentElement.thread;
}
}
class OverlayBrowsing {
constructor(
public readonly hashtag: string,
public readonly account: string,
public readonly thread: string) {
console.warn(`OverlayBrowsing: ${hashtag} ${account} ${thread}`);
if (hashtag) {
this.type = OverlayEnum.hashtag;
} else if (account) {
this.type = OverlayEnum.account;
} else if (thread) {
this.type = OverlayEnum.thread;
} else {
throw Error('NotImplemented');
}
}
type: OverlayEnum;
}
enum OverlayEnum {
unknown = 0,
hashtag = 1,
account = 2,
thread = 3
}

View File

@ -1,7 +1,9 @@
<div class="stream-column">
<app-stream-overlay class="stream-overlay" *ngIf="overlayActive"
(closeOverlay)="closeOverlay()" [browseAccount]="overlayAccountToBrowse"></app-stream-overlay>
(closeOverlay)="closeOverlay()"
[browseAccount]="overlayAccountToBrowse"
[browseHashtag]="overlayHashtagToBrowse"></app-stream-overlay>
<div class="stream-column__stream-header">
<a href title="return to top" (click)="goToTop()">
@ -11,7 +13,7 @@
<div class="stream-toots flexcroll" #statusstream (scroll)="onScroll()">
<!-- data-simplebar -->
<div class="stream-toots__status" *ngFor="let statusWrapper of statuses">
<app-status [statusWrapper]="statusWrapper" (browseAccount)="browseAccount($event)"></app-status>
<app-status [statusWrapper]="statusWrapper" (browseAccount)="browseAccount($event)" (browseHashtag)="browseHashtag($event)"></app-status>
</div>
</div>
</div>

View File

@ -1,4 +1,5 @@
@import "variables";
@import "commons";
.stream-column {
position: relative;
@ -31,25 +32,25 @@
}
}
.flexcroll {
scrollbar-face-color: #08090d;
scrollbar-shadow-color: #08090d;
scrollbar-highlight-color: #08090d;
scrollbar-3dlight-color: #08090d;
scrollbar-darkshadow-color: #08090d;
scrollbar-track-color: #08090d;
scrollbar-arrow-color: #08090d;
&::-webkit-scrollbar {
width: 7px;
}
&::-webkit-scrollbar-thumb {
-webkit-border-radius: 0px;
border-radius: 0px;
// background: #08090d;
background: lighten($color-primary, 5);
// -webkit-box-shadow: inset 0 0 3px rgba(0, 0, 0, 0.5);
}
}
// .flexcroll {
// scrollbar-face-color: #08090d;
// scrollbar-shadow-color: #08090d;
// scrollbar-highlight-color: #08090d;
// scrollbar-3dlight-color: #08090d;
// scrollbar-darkshadow-color: #08090d;
// scrollbar-track-color: #08090d;
// scrollbar-arrow-color: #08090d;
// &::-webkit-scrollbar {
// width: 7px;
// }
// &::-webkit-scrollbar-thumb {
// -webkit-border-radius: 0px;
// border-radius: 0px;
// // background: #08090d;
// background: lighten($color-primary, 5);
// // -webkit-box-shadow: inset 0 0 3px rgba(0, 0, 0, 0.5);
// }
// }
.stream-overlay {
position: absolute;

View File

@ -22,7 +22,8 @@ export class StreamComponent implements OnInit {
private bufferWasCleared: boolean;
overlayActive: boolean;
overlayAccountToBrowse: Account;
overlayAccountToBrowse: string;
overlayHashtagToBrowse: string;
@Input()
set streamElement(streamElement: StreamElement) {
@ -50,17 +51,20 @@ export class StreamComponent implements OnInit {
ngOnInit() {
}
browseAccount(account: Account): void {
browseAccount(account: string): void {
this.overlayAccountToBrowse = account;
this.overlayActive = true;
this.overlayHashtagToBrowse = null;
this.overlayActive = true;
}
browseHashtag(hashtag: any): void {
console.warn('browseHashtag');
console.warn(hashtag);
browseHashtag(hashtag: string): void {
console.warn(`browseHashtag ${hashtag}`);
this.overlayAccountToBrowse = null;
this.overlayHashtagToBrowse = hashtag;
this.overlayActive = true;
}
browseThread(thread: any): void {
browseThread(thread: string): void {
console.warn('browseThread');
console.warn(thread);
}

View File

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

View File

@ -1,48 +1,52 @@
@import "variables";
.profile-header {
position: relative;
height: 140px;
overflow: hidden; // background-color: black;
border-bottom: 1px solid black;
& h2 {
font-size: $default-font-size;
}
&__header {
position: absolute;
// width: calc(100%);
@import "commons";
.profile {
overflow: auto;
height: calc(100% - 30px);
width: calc(100%);
height: auto;
&-header {
background-size: cover;
position: relative; // height: 140px;
overflow: hidden; // background-color: black;
border-bottom: 1px solid black;
& h2 {
font-size: $default-font-size;
}
&__inner {
overflow: auto;
height: 160px;
background-color: rgba(0, 0, 0, .45);
}
&__avatar {
position: absolute;
top: 15px;
left: 15px;
width: 80px;
border-radius: 50%;
}
&__display-name {
position: absolute;
top: 105px;
left: 15px;
color: white;
}
&__fullhandle a {
position: absolute;
top: 130px;
left: 15px;
color: white;
}
}
float: left;
display: block;
opacity: 0.3;
&-description {
padding: 10px 10px 15px 10px;
font-size: 13px;
border-bottom: 1px solid black;
}
&__avatar {
position: absolute;
top: 15px;
left: 15px;
width: 80px;
border-radius: 50%; // border: 1px solid black;
// background-color: black;
}
&__display-name {
position: absolute;
top: 45px;
left: 115px;
// font-weight: bold;
color: white;
}
&__fullhandle {
position: absolute;
top: 105px;
left: 15px;
color: white;
}
}
.profile-description {
padding: 5px 10px 0 10px;
font-size: 13px;
border-bottom: 1px solid black;
&-no-toots {
text-align: center;
margin: 15px;
border: 2px solid whitesmoke;
}
}

View File

@ -1,5 +1,8 @@
import { Component, OnInit, Input } from '@angular/core';
import { Account } from "../../../services/models/mastodon.interfaces";
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { Account, Status, Results } from "../../../services/models/mastodon.interfaces";
import { MastodonService } from '../../../services/mastodon.service';
import { ToolsService } from '../../../services/tools.service';
import { StatusWrapper } from '../stream.component';
@Component({
selector: 'app-user-profile',
@ -10,17 +13,95 @@ export class UserProfileComponent implements OnInit {
account: Account;
hasNote: boolean;
isLoading: boolean;
statusLoading: boolean;
error: string;
statuses: StatusWrapper[] = [];
private accountName: string;
@Output() browseAccount = new EventEmitter<string>();
@Output() browseHashtag = new EventEmitter<string>();
@Input('currentAccount')
set currentAccount(account: Account) {
this.account = account;
this.hasNote = account && account.note && account.note !== '<p></p>';
console.warn('currentAccount');
console.warn(account);
//set currentAccount(account: Account) {
set currentAccount(accountName: string) {
this.statuses.length = 0;
this.isLoading = true;
this.loadAccount(accountName)
.then((account: Account) => {
this.account = account;
return this.getStatuses(this.account);
})
.catch(err => {
this.error = 'Error when retrieving account';
this.isLoading = false;
this.statusLoading = false;
console.warn(this.error);
});
// this.account = account;
// this.hasNote = account && account.note && account.note !== '<p></p>';
// console.warn('currentAccount');
// console.warn(account);
// this.getStatuses(account);
}
constructor() { }
constructor(
private readonly mastodonService: MastodonService,
private readonly toolsService: ToolsService) { }
ngOnInit() {
}
accountSelected(accountName: string): void {
this.browseAccount.next(accountName);
}
hashtagSelected(hashtag: string): void {
this.browseHashtag.next(hashtag);
}
private loadAccount(accountName: string): Promise<Account> {
this.account = null;
this.accountName = accountName;
let selectedAccounts = this.toolsService.getSelectedAccounts();
if (selectedAccounts.length === 0) {
this.error = 'no user selected';
console.error(this.error);
return Promise.resolve(null);
}
this.isLoading = true;
return this.mastodonService.search(selectedAccounts[0], accountName, true)
.then((result: Results) => {
console.warn(result);
this.isLoading = false;
return result.accounts[0];
});
}
private getStatuses(account: Account): Promise<void> {
let selectedAccounts = this.toolsService.getSelectedAccounts();
if (selectedAccounts.length === 0) return;
this.statusLoading = true;
return this.mastodonService.getAccountStatuses(selectedAccounts[0], account.id, false, false, true, null, null, 40)
.then((result: Status[]) => {
for (const status of result) {
const wrapper = new StatusWrapper(status, selectedAccounts[0]);
this.statuses.push(wrapper);
}
this.statusLoading = false;
});
// .catch(err => {
// })
// .then(() => {
// this.statusLoading = false;
// });
}
}

View File

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

View File

@ -0,0 +1,104 @@
//https://github.com/AndrewPoyntz/time-ago-pipe/issues/6#issuecomment-313726956
import { Pipe, PipeTransform, NgZone } from "@angular/core";
import { Observable, Observer } from 'rxjs';
interface processOutput {
text: string; // Convert timestamp to string
timeToUpdate: number; // Time until update in milliseconds
}
@Pipe({
name: 'timeAgo',
pure: true
})
export class TimeAgoPipe implements PipeTransform {
constructor(private ngZone: NgZone) { }
private process = (timestamp: number): processOutput => {
let text: string;
let timeToUpdate: number;
const now = new Date();
// Time ago in milliseconds
const timeAgo: number = now.getTime() - timestamp;
const seconds = timeAgo / 1000;
const minutes = seconds / 60;
const hours = minutes / 60;
const days = hours / 24;
// const months = days / 30.416;
// const years = days / 365;
if (seconds <= 60) {
text = Math.round(seconds) + 's';
} else if (minutes <= 90) {
text = Math.round(minutes) + 'm';
} else if (hours <= 24) {
text = Math.round(hours) + 'h';
} else {
text = Math.round(days) + 'd';
}
if (minutes < 1) {
// update every 2 secs
timeToUpdate = 2 * 1000;
} else if (hours < 1) {
// update every 30 secs
timeToUpdate = 30 * 1000;
} else if (days < 1) {
// update every 5 mins
timeToUpdate = 300 * 1000;
} else {
// update every hour
timeToUpdate = 3600 * 1000;
}
return {
text,
timeToUpdate
};
}
public transform = (value: string | Date): Observable<string> => {
let d: Date;
if (value instanceof Date) {
d = value;
}
else {
d = new Date(value);
}
// time value in milliseconds
const timestamp = d.getTime();
let timeoutID: any;
return Observable.create((observer: Observer<string>) => {
let latestText = '';
// Repeatedly set new timeouts for new update checks.
const registerUpdate = () => {
const processOutput = this.process(timestamp);
if (processOutput.text !== latestText) {
latestText = processOutput.text;
this.ngZone.run(() => {
observer.next(latestText);
});
}
timeoutID = setTimeout(registerUpdate, processOutput.timeToUpdate);
};
this.ngZone.runOutsideAngular(registerUpdate);
// Return teardown function
const teardownFunction = () => {
if (timeoutID) {
clearTimeout(timeoutID);
}
};
return teardownFunction;
});
}
}

View File

@ -117,6 +117,23 @@ export class MastodonService {
return this.httpClient.get<Results>(route, { headers: headers }).toPromise()
}
getAccountStatuses(account: AccountInfo, targetAccountId: number, onlyMedia: boolean, onlyPinned: boolean, excludeReplies: boolean, maxId: string, sinceId: string, limit: number = 20): Promise<Status[]>{
const route = `https://${account.instance}${this.apiRoutes.getAccountStatuses}`.replace('{0}', targetAccountId.toString());
let params = `?only_media=${onlyMedia}&pinned=${onlyPinned}&exclude_replies=${excludeReplies}&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<Status[]>(route+params, { headers: headers }).toPromise();
}
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}` });
return this.httpClient.get<Account[]>(route, { headers: headers }).toPromise()
}
reblog(account: AccountInfo, status: Status): Promise<Status> {
const route = `https://${account.instance}${this.apiRoutes.reblogStatus}`.replace('{0}', status.id);
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });

View File

@ -39,11 +39,12 @@ export class StreamingWrapper {
this.eventSource.onmessage = x => this.statusParsing(<WebSocketEvent>JSON.parse(x.data));
this.eventSource.onerror = x => this.webSocketGotError(x);
this.eventSource.onopen = x => console.log(x);
this.eventSource.onclose = x => this.webSocketClosed(route, x);
this.eventSource.onclose = x => this.webSocketClosed(route, x);
}
private errorClosing: boolean;
private webSocketGotError(x: Event) {
console.log(x);
this.errorClosing = true;
}
@ -52,7 +53,36 @@ export class StreamingWrapper {
console.log(x);
if (this.errorClosing) {
this.mastodonService.getTimeline(this.accountInfo, this.streamType, null, this.since_id)
this.pullNewStatuses(domain);
// this.mastodonService.getTimeline(this.accountInfo, this.streamType, null, this.since_id)
// .then((status: Status[]) => {
// // status = status.sort((n1, n2) => { return (<number>n1.id) < (<number>n2.id); });
// status = status.sort((a, b) => a.id.localeCompare(b.id));
// for (const s of status) {
// const update = new StatusUpdate();
// update.status = s;
// update.type = EventEnum.update;
// this.since_id = update.status.id;
// this.statusUpdateSubjet.next(update);
// }
// })
// .catch(err => {
// console.error(err);
// })
// .then(() => {
// setTimeout(() => { this.start(domain) }, 20 * 1000);
// });
this.errorClosing = false;
} else {
setTimeout(() => { this.start(domain) }, 5000);
}
}
private pullNewStatuses(domain){
this.mastodonService.getTimeline(this.accountInfo, this.streamType, null, this.since_id)
.then((status: Status[]) => {
// status = status.sort((n1, n2) => { return (<number>n1.id) < (<number>n2.id); });
status = status.sort((a, b) => a.id.localeCompare(b.id));
@ -68,13 +98,9 @@ export class StreamingWrapper {
console.error(err);
})
.then(() => {
setTimeout(() => { this.start(domain) }, 20 * 1000);
// setTimeout(() => { this.start(domain) }, 20 * 1000);
setTimeout(() => { this.pullNewStatuses(domain) }, 15 * 1000);
});
this.errorClosing = false;
} else {
setTimeout(() => { this.start(domain) }, 5000);
}
}
private statusParsing(event: WebSocketEvent) {

View File

@ -0,0 +1,15 @@
import { TestBed, inject } from '@angular/core/testing';
import { ToolsService } from './tools.service';
xdescribe('ToolsService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ToolsService]
});
});
it('should be created', inject([ToolsService], (service: ToolsService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,20 @@
import { Injectable } from '@angular/core';
import { Store } from '@ngxs/store';
import { AccountInfo } from '../states/accounts.state';
@Injectable({
providedIn: 'root'
})
export class ToolsService {
constructor( private readonly store: Store) { }
getSelectedAccounts(): AccountInfo[] {
var regAccounts = <AccountInfo[]>this.store.snapshot().registeredaccounts.accounts;
return regAccounts.filter(x => x.isSelected);
}
}

View File

@ -2,4 +2,24 @@
width: 40px;
display: block;
margin: 5px auto;
}
.flexcroll {
scrollbar-face-color: #08090d;
scrollbar-shadow-color: #08090d;
scrollbar-highlight-color: #08090d;
scrollbar-3dlight-color: #08090d;
scrollbar-darkshadow-color: #08090d;
scrollbar-track-color: #08090d;
scrollbar-arrow-color: #08090d;
&::-webkit-scrollbar {
width: 7px;
}
&::-webkit-scrollbar-thumb {
-webkit-border-radius: 0px;
border-radius: 0px;
// background: #08090d;
background: lighten($color-primary, 5);
// -webkit-box-shadow: inset 0 0 3px rgba(0, 0, 0, 0.5);
}
}