Merge pull request #145 from NicolasConstant/develop

0.13.0 Merge
This commit is contained in:
Nicolas Constant 2019-07-29 22:49:30 -04:00 committed by GitHub
commit 9c01350f07
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1201 additions and 222 deletions

View File

@ -21,7 +21,8 @@
"src/favicon.ico"
],
"styles": [
"src/sass/styles.scss"
"src/sass/styles.scss",
"node_modules/@ctrl/ngx-emoji-mart/picker.css"
],
"stylePreprocessorOptions": {
"includePaths": [

10
package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "sengi",
"version": "0.11.0",
"version": "0.12.2",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -711,6 +711,14 @@
}
}
},
"@ctrl/ngx-emoji-mart": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@ctrl/ngx-emoji-mart/-/ngx-emoji-mart-0.17.0.tgz",
"integrity": "sha512-gdHM/OPTbqWMIlFPAbjgAPo5BGsjkehILCInw5OttuT25HMZXJFjWVpi6vGixNVrAs8kz6sTYM/wbldS5GP9yQ==",
"requires": {
"tslib": "^1.9.0"
}
},
"@fortawesome/angular-fontawesome": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/angular-fontawesome/-/angular-fontawesome-0.3.0.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "sengi",
"version": "0.12.2",
"version": "0.13.0",
"license": "AGPL-3.0-or-later",
"main": "main-electron.js",
"description": "A multi-account desktop client for Mastodon and Pleroma",
@ -37,6 +37,7 @@
"@angular/platform-browser": "^7.2.7",
"@angular/platform-browser-dynamic": "^7.2.7",
"@angular/router": "^7.2.7",
"@ctrl/ngx-emoji-mart": "^0.17.0",
"@fortawesome/angular-fontawesome": "^0.3.0",
"@fortawesome/fontawesome-svg-core": "^1.2.13",
"@fortawesome/free-brands-svg-icons": "^5.7.0",

View File

@ -9,9 +9,11 @@ import { RouterModule, Routes } from "@angular/router";
import { NgxsModule } from '@ngxs/store';
import { NgxsStoragePluginModule } from '@ngxs/storage-plugin';
import { OverlayModule } from '@angular/cdk/overlay';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { ContextMenuModule } from 'ngx-contextmenu';
import { PickerModule } from '@ctrl/ngx-emoji-mart';
import { AppComponent } from "./app.component";
import { LeftSideBarComponent } from "./components/left-side-bar/left-side-bar.component";
@ -63,77 +65,87 @@ import { ListEditorComponent } from './components/floating-column/manage-account
import { ListAccountComponent } from './components/floating-column/manage-account/my-account/list-editor/list-account/list-account.component';
import { PollComponent } from './components/stream/status/poll/poll.component';
import { TimeLeftPipe } from './pipes/time-left.pipe';
import { AutosuggestComponent } from './components/create-status/autosuggest/autosuggest.component';
import { EmojiPickerComponent } from './components/create-status/emoji-picker/emoji-picker.component';
const routes: Routes = [
{ path: "", redirectTo: "home", pathMatch: "full" },
{ path: "home", component: StreamsMainDisplayComponent },
{ path: "register", component: RegisterNewAccountComponent},
{ path: "**", redirectTo: "home" }
{ path: "", redirectTo: "home", pathMatch: "full" },
{ path: "home", component: StreamsMainDisplayComponent },
{ path: "register", component: RegisterNewAccountComponent },
{ path: "**", redirectTo: "home" }
];
@NgModule({
declarations: [
AppComponent,
LeftSideBarComponent,
StreamsMainDisplayComponent,
StreamComponent,
StreamsSelectionFooterComponent,
StatusComponent,
RegisterNewAccountComponent,
AccountIconComponent,
FloatingColumnComponent,
ManageAccountComponent,
AddNewStatusComponent,
AttachementsComponent,
SettingsComponent,
AddNewAccountComponent,
SearchComponent,
ActionBarComponent,
WaitingAnimationComponent,
UserProfileComponent,
ThreadComponent,
HashtagComponent,
StreamOverlayComponent,
DatabindedTextComponent,
TimeAgoPipe,
StreamStatusesComponent,
StreamEditionComponent,
TutorialComponent,
NotificationHubComponent,
MediaViewerComponent,
CreateStatusComponent,
MediaComponent,
MyAccountComponent,
FavoritesComponent,
DirectMessagesComponent,
MentionsComponent,
NotificationsComponent,
AccountEmojiPipe,
CardComponent,
ListEditorComponent,
ListAccountComponent,
PollComponent,
TimeLeftPipe
],
imports: [
FontAwesomeModule,
BrowserModule,
HttpModule,
HttpClientModule,
FormsModule,
RouterModule.forRoot(routes),
declarations: [
AppComponent,
LeftSideBarComponent,
StreamsMainDisplayComponent,
StreamComponent,
StreamsSelectionFooterComponent,
StatusComponent,
RegisterNewAccountComponent,
AccountIconComponent,
FloatingColumnComponent,
ManageAccountComponent,
AddNewStatusComponent,
AttachementsComponent,
SettingsComponent,
AddNewAccountComponent,
SearchComponent,
ActionBarComponent,
WaitingAnimationComponent,
UserProfileComponent,
ThreadComponent,
HashtagComponent,
StreamOverlayComponent,
DatabindedTextComponent,
TimeAgoPipe,
StreamStatusesComponent,
StreamEditionComponent,
TutorialComponent,
NotificationHubComponent,
MediaViewerComponent,
CreateStatusComponent,
MediaComponent,
MyAccountComponent,
FavoritesComponent,
DirectMessagesComponent,
MentionsComponent,
NotificationsComponent,
AccountEmojiPipe,
CardComponent,
ListEditorComponent,
ListAccountComponent,
PollComponent,
TimeLeftPipe,
AutosuggestComponent,
EmojiPickerComponent
],
entryComponents: [
EmojiPickerComponent
],
imports: [
FontAwesomeModule,
BrowserModule,
HttpModule,
HttpClientModule,
FormsModule,
PickerModule,
OverlayModule,
RouterModule.forRoot(routes),
NgxsModule.forRoot([
RegisteredAppsState,
AccountsState,
StreamsState,
SettingsState
]),
NgxsStoragePluginModule.forRoot(),
ContextMenuModule.forRoot()
],
providers: [AuthService, NavigationService, NotificationService, MastodonService, StreamingService],
bootstrap: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
NgxsModule.forRoot([
RegisteredAppsState,
AccountsState,
StreamsState,
SettingsState
]),
NgxsStoragePluginModule.forRoot(),
ContextMenuModule.forRoot()
],
providers: [AuthService, NavigationService, NotificationService, MastodonService, StreamingService],
bootstrap: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule { }

View File

@ -0,0 +1,17 @@
<div class="autosuggest" *ngIf="accounts.length > 0 || hashtags.length > 0">
<a href *ngFor="let a of accounts"
title="@{{a.account.acct}}"
(click)="accountSelected(a)"
class="autosuggest__entry autosuggest__account"
[class.autosuggest__entry--selected]="a.selected">
<img class="autosuggest__account--avatar" src="{{ a.account.avatar }}" /> <span class="autosuggest__account--text"><span class="autosuggest__account--handle">{{ a.account.username }}</span> @{{ a.account.acct }}</span>
</a>
<a href *ngFor="let h of hashtags"
title="#{{h.hashtag}}"
(click)="hashtagSelected(h)"
class="autosuggest__entry"
[class.autosuggest__entry--selected]="h.selected">
<span class="autosuggest__account--handle">#{{ h.hashtag }}</span>
</a>
</div>

View File

@ -0,0 +1,54 @@
@import "variables";
.autosuggest {
background-color: $autosuggest-background;
// border: solid $autosuggest-background;
// border-width: 0 1px 1px 1px;
&__entry {
display: block;
padding: 1px 5px;
color: $autosuggest-entry-color;
background-color: $autosuggest-entry-background;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
width: calc(100%);
&:hover, &--selected {
color: $autosuggest-entry-color-hover;
text-decoration: none;
background-color: $autosuggest-entry-background-hover;
}
}
&__account {
padding: 5px 5px 5px 5px;
&:last-child{
padding: 5px 5px 5px 5px;
}
&--avatar {
width: 25px;
margin-right: 7px;
}
&--text {
position: relative;
top: 1px;
}
&--handle {
color: $autosuggest-entry-handle-color;
}
// &--acct {
// color: $autosuggest-entry-color;
// }
}
&__entry:hover &__account--handle, &__entry--selected &__account--handle {
color: $autosuggest-entry-handle-color-hover;
}
}

View File

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

View File

@ -0,0 +1,182 @@
import { Component, OnInit, Input, Output, EventEmitter, OnDestroy } from '@angular/core';
import { ToolsService } from '../../../services/tools.service';
import { MastodonService } from '../../../services/mastodon.service';
import { NotificationService } from '../../../services/notification.service';
import { Results, Account } from '../../../services/models/mastodon.interfaces';
import { Actions } from '@ngxs/store';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-autosuggest',
templateUrl: './autosuggest.component.html',
styleUrls: ['./autosuggest.component.scss']
})
export class AutosuggestComponent implements OnInit, OnDestroy {
private lastPatternUsed: string;
private lastPatternUsedWtType: string;
accounts: SelectableAccount[] = [];
hashtags: SelectableHashtag[] = [];
@Output() suggestionSelectedEvent = new EventEmitter<AutosuggestSelection>();
@Output() hasSuggestionsEvent = new EventEmitter<boolean>();
private _pattern: string;
@Input('pattern')
set pattern(value: string) {
if (value) {
this._pattern = value;
this.analysePattern(value);
} else {
this.accounts.length = 0;
this.hashtags.length = 0;
}
}
get pattern(): string {
return this._pattern;
}
@Input() autoSuggestUserActionsStream: EventEmitter<AutosuggestUserActionEnum>;
private autoSuggestUserActionsSub: Subscription;
constructor(
private readonly notificationService: NotificationService,
private readonly toolsService: ToolsService,
private readonly mastodonService: MastodonService) { }
ngOnInit() {
if (this.autoSuggestUserActionsStream) {
this.autoSuggestUserActionsSub = this.autoSuggestUserActionsStream.subscribe((action: AutosuggestUserActionEnum) => {
this.processUserInput(action);
});
}
}
ngOnDestroy(): void {
if (this.autoSuggestUserActionsSub) this.autoSuggestUserActionsSub.unsubscribe();
}
private analysePattern(value: string) {
const selectedAccount = this.toolsService.getSelectedAccounts()[0];
const isAccount = value[0] === '@';
const pattern = value.substring(1);
this.lastPatternUsed = pattern;
this.lastPatternUsedWtType = value;
this.mastodonService.search(selectedAccount, pattern, false)
.then((results: Results) => {
if (this.lastPatternUsed !== pattern) return;
this.accounts.length = 0;
this.hashtags.length = 0;
if (isAccount) {
for (let account of results.accounts) {
if (account.acct != this.lastPatternUsed) {
this.accounts.push(new SelectableAccount(account));
this.accounts[0].selected = true;
if (this.accounts.length > 7) return;
}
}
}
else {
for (let hashtag of results.hashtags) {
if (hashtag.includes(this.lastPatternUsed) && hashtag !== this.lastPatternUsed) {
this.hashtags.push(new SelectableHashtag(hashtag));
this.hashtags[0].selected = true;
if (this.hashtags.length > 7) return;
}
}
}
})
.then(() => {
if (this.hashtags.length > 0 || this.accounts.length > 0) {
this.hasSuggestionsEvent.next(true);
} else {
this.hasSuggestionsEvent.next(false);
}
})
.catch(err => {
this.notificationService.notifyHttpError(err);
});
}
private processUserInput(action: AutosuggestUserActionEnum) {
const isAutosuggestingHashtag = this.hashtags.length > 0;
switch (action) {
case AutosuggestUserActionEnum.Validate:
if (isAutosuggestingHashtag) {
let selection = this.hashtags.find(x => x.selected);
this.hashtagSelected(selection);
} else {
let selection = this.accounts.find(x => x.selected);
this.accountSelected(selection);
}
break;
case AutosuggestUserActionEnum.MoveDown:
if (isAutosuggestingHashtag) {
let selectionIndex = this.hashtags.findIndex(x => x.selected);
if (selectionIndex < (this.hashtags.length - 1)) {
this.hashtags[selectionIndex].selected = false;
this.hashtags[selectionIndex + 1].selected = true;
}
} else {
let selectionIndex = this.accounts.findIndex(x => x.selected);
if (selectionIndex < (this.accounts.length - 1)) {
this.accounts[selectionIndex].selected = false;
this.accounts[selectionIndex + 1].selected = true;
}
}
break;
case AutosuggestUserActionEnum.MoveUp:
if (isAutosuggestingHashtag) {
let selectionIndex = this.hashtags.findIndex(x => x.selected);
if (selectionIndex > 0) {
this.hashtags[selectionIndex].selected = false;
this.hashtags[selectionIndex - 1].selected = true;
}
} else {
let selectionIndex = this.accounts.findIndex(x => x.selected);
if (selectionIndex > 0) {
this.accounts[selectionIndex].selected = false;
this.accounts[selectionIndex - 1].selected = true;
}
}
break;
}
}
accountSelected(selAccount: SelectableAccount): boolean {
const fullHandle = this.toolsService.getAccountFullHandle(selAccount.account);
this.suggestionSelectedEvent.next(new AutosuggestSelection(this.lastPatternUsedWtType, fullHandle));
return false;
}
hashtagSelected(selHashtag: SelectableHashtag): boolean {
this.suggestionSelectedEvent.next(new AutosuggestSelection(this.lastPatternUsedWtType, `#${selHashtag.hashtag}`));
return false;
}
}
class SelectableAccount {
constructor(public account: Account, public selected: boolean = false) {
}
}
class SelectableHashtag {
constructor(public hashtag: string, public selected: boolean = false) {
}
}
export class AutosuggestSelection {
constructor(public pattern: string, public autosuggest: string) {
}
}
export enum AutosuggestUserActionEnum {
MoveDown,
MoveUp,
Validate
}

View File

@ -1,28 +1,67 @@
<form class="status-form" (ngSubmit)="onSubmit()">
<div class="status-form__sending" *ngIf="isSending">
<app-waiting-animation class="waiting-icon"></app-waiting-animation>
</div>
<form class="status-editor" (ngSubmit)="onSubmit()">
<input [(ngModel)]="title" type="text" class="form-control form-control-sm status-editor__title" name="title"
autocomplete="off" placeholder="Title, Content Warning (optional)" title="title, content warning (optional)" />
<input [(ngModel)]="title" type="text" class="form-control form-control-sm" name="title" autocomplete="off"
placeholder="Title, Content Warning (optional)" title="title, content warning (optional)" />
<a class="status-editor__emoji" title="Insert Emoji"
#emojiButton href (click)="openEmojiPicker($event)">
<img class="status-editor__emoji--image" src="/assets/emoji/72x72/1f636.png">
</a>
<textarea #reply [(ngModel)]="status" name="status"
class="form-control form-control-sm status-form__status flexcroll" rows="5" required title="content"
placeholder="What's in your mind?" (keydown.control.enter)="onCtrlEnter()"></textarea>
<textarea #reply [(ngModel)]="status" name="status" class="form-control form-control-sm status-editor__content"
rows="5" required title="content" placeholder="What's in your mind?" (keydown.control.enter)="onCtrlEnter()"
(keydown)="handleKeyDown($event)" (blur)="statusTextEditorLostFocus()">
</textarea>
<div class="status-form__mention-error" *ngIf="mentionTooFarAwayError">Error: mentions must be placed closer to the
<div class="status-editor__mention-error" *ngIf="mentionTooFarAwayError">Error: mentions must be placed closer to
the
start in order to use multiposting.</div>
<select class="form-control form-control-sm form-control--privacy" id="privacy" name="privacy"
[(ngModel)]="selectedPrivacy">
<option *ngFor="let p of privacyList" [ngValue]="p">{{p}}</option>
</select>
<div class="status-form__counter">
<span class="status-form__counter--count">{{charCountLeft}}</span> <span
class="status-form__counter--posts">{{postCounts - 1}}/{{postCounts}}</span>
</div>
<button type="submit" class="btn btn-sm btn-custom-primary" *ngIf="statusReplyingToWrapper">REPLY!</button>
<button type="submit" class="btn btn-sm btn-custom-primary" *ngIf="!statusReplyingToWrapper">POST!</button>
<app-autosuggest class="status-editor__autosuggest" *ngIf="autosuggestData" [pattern]="autosuggestData"
[autoSuggestUserActionsStream]="autoSuggestUserActionsStream"
(suggestionSelectedEvent)="suggestionSelected($event)" (hasSuggestionsEvent)="suggestionsChanged($event)">
</app-autosuggest>
<div class="status-editor__footer">
<button type="submit" title="reply" class="status-editor__footer--send-button" *ngIf="statusReplyingToWrapper">
<span *ngIf="!isSending">REPLY!</span>
<app-waiting-animation class="waiting-icon" *ngIf="isSending"></app-waiting-animation>
</button>
<button type="submit" title="post" class="status-editor__footer--send-button" *ngIf="!statusReplyingToWrapper">
<span *ngIf="!isSending">POST!</span>
<app-waiting-animation class="waiting-icon" *ngIf="isSending"></app-waiting-animation>
</button>
<div class="status-editor__footer__counter">
<div class="status-editor__footer__counter--posts" title="number of statuses">
{{postCounts - 1}}/{{postCounts}}</div>
<div class="status-editor__footer__counter--count" title="chars left">{{charCountLeft}}</div>
</div>
<a href class="status-editor__footer--link" title="add media" (click)="addMedia()">
<fa-icon [icon]="faPaperclip"></fa-icon>
</a>
<input #fileInput type="file" id="file" style="display: none;" (change)="handleFileInput($event.target.files)">
<a href class="status-editor__footer--link" title="{{ selectedPrivacy }}" (click)="onContextMenu($event)">
<fa-icon [icon]="faGlobeAmericas" *ngIf="selectedPrivacy === 'Public'"></fa-icon>
<fa-icon [icon]="faLockOpen" *ngIf="selectedPrivacy === 'Unlisted'"></fa-icon>
<fa-icon [icon]="faLock" *ngIf="selectedPrivacy === 'Follows-only'"></fa-icon>
<fa-icon [icon]="faEnvelope" *ngIf="selectedPrivacy === 'DM'"></fa-icon>
</a>
</div>
<context-menu #contextMenu>
<ng-template contextMenuItem (execute)="changePrivacy('Public')">
<fa-icon [icon]="faGlobeAmericas" class="context-menu-icon"></fa-icon> Public
</ng-template>
<ng-template contextMenuItem (execute)="changePrivacy('Unlisted')">
<fa-icon [icon]="faLockOpen" class="context-menu-icon"></fa-icon> Unlisted
</ng-template>
<ng-template contextMenuItem (execute)="changePrivacy('Follows-only')">
<fa-icon [icon]="faLock" class="context-menu-icon"></fa-icon> Followers-only
</ng-template>
<ng-template contextMenuItem (execute)="changePrivacy('DM')">
<fa-icon [icon]="faEnvelope" class="context-menu-icon"></fa-icon> Direct
</ng-template>
</context-menu>
<app-media></app-media>
</form>

View File

@ -2,96 +2,198 @@
@import "commons";
@import "panel";
@import "buttons";
@import "mixins";
$btn-send-status-width: 60px;
$counter-width: 90px;
// @import "~@ctrl/ngx-emoji-mart/picker";
.form-control {
margin: 0 0 5px 5px;
width: calc(100% - 10px);
background-color: $column-color;
background-color: $status-editor-background;
border-color: $status-secondary-color;
color: #fff;
color: $status-editor-color;
font-size: $default-font-size;
&:focus {
box-shadow: none;
}
&--privacy {
display: inline-block;
width: calc(100% - 15px - #{$btn-send-status-width} - #{$counter-width});
}
}
.status-editor {
position: relative;
font-size: $default-font-size;
&__title {
background-color: $status-editor-title-background;
color: $status-editor-color;
border-top-left-radius: 2px;
border-top-right-radius: 2px;
border-width: 0;
margin-bottom: 0;
}
&__emoji {
position: absolute;
top: 37px;
right: 10px;
&--image {
transition: all .2s;
width: 24px;
height: 24px;
-webkit-filter: grayscale(100%);
-moz-filter: grayscale(100%);
-ms-filter: grayscale(100%);
-o-filter: grayscale(100%);
filter: gray;
opacity: .7;
&:hover {
filter: none;
-webkit-filter: grayscale(0%);
-moz-filter: grayscale(0%);
-ms-filter: grayscale(0%);
-o-filter: grayscale(0%);
opacity: 1;
}
}
}
&__content {
border-width: 0;
background-color: $status-editor-background;
color: $status-editor-color;
margin-bottom: 0;
resize: none;
border: none;
overflow: auto;
outline: none;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
min-height: 110px;
height: 110px;
padding-bottom: 10px;
padding-right: 30px;
//border-bottom: 1px solid black;
&::-webkit-resizer {
width: 0px;
height: 0px;
}
&::-webkit-scrollbar {
width: 0px;
}
}
&__mention-error {
background-color: $status-editor-background;
color: rgb(255, 34, 34);
padding: 5px 10px;
margin: 0 5px;
}
&__autosuggest {
display: block;
margin: 0 5px;
}
&__footer {
height: 34px;
margin: 0 5px;
border-width: 0;
background-color: $status-editor-footer-background;
&--link {
color: $status-editor-footer-link-color;
display: inline-block;
padding: 5px;
margin: 2px 0 0 5px;
}
&--send-button {
@include clearButton;
transition: all .2s;
float: right;
padding: 0 15px 0 15px;
height: 34px;
background-color: $status-editor-footer-background;
&:hover {
background-color: lighten($status-editor-footer-background, 20%);
background-color: darken($status-editor-footer-background, 20%);
}
& span {
margin: 0;
padding: 0;
}
}
&__counter {
float: right;
height: 34px;
padding: 6px 7px 0 7px;
vertical-align: center;
&--count {
display: block;
margin-right: 40px
}
&--posts {
display: block;
float: right;
}
}
}
}
.btn-custom-primary {
display: inline-block;
width: $btn-send-status-width;
position: relative;
top: -1px;
left: 5px; // background-color: orange;
// border-color: orange;
// color: black;
font-weight: 500; // &:hover {
// }
// &:focus {
// border-color: darkblue;
// }
left: 5px;
font-weight: 500;
}
.status-form {
.context-menu-icon {
position: relative;
font-size: $default-font-size;
&__sending {
position: absolute;
top: 0px;
left: 4px;
right: 4px;
bottom: 4px;
background-color: rgba($column-color, .75);
z-index: 2;
&--waiting {
margin-top: calc(25%);
}
}
&__counter {
display: inline-block;
border: 1px solid $status-secondary-color;
margin-left: 5px;
width: calc(#{$counter-width} - 5px);
height: 32px;
position: relative;
top: 0px;
padding: 4px 7px 0 7px; // color: lighten($font-link-primary-hover, 10);
// position: relative;
// overflow: hidden;
&--count {
// position: absolute;
// left: 0;
// overflow: hidden;
// outline: 1px solid greenyellow;
}
&--posts {
// position: absolute;
// right: 0;
margin-left: 10px;
float: right;
}
}
&__status {
&::-webkit-resizer {
// border: 2px solid black;
background: $font-link-primary-hover;
width: 10px;
height: 10px;
// box-shadow: 0 0 5px 5px blue;
// outline: 2px solid yellow;
}
left: -3px;
font-size: 12px;
color: #1f1f1f;
}
&::-webkit-scrollbar {
width: 12px;
}
}
.emojipicker {
&__mention-error {
border: 2px dashed red;
padding: 5px 10px;
margin: 5px;
}
}
font-size: $default-font-size !important;
}
@import '~@angular/cdk/overlay-prebuilt.css';
// ::ng-deep .cdk-overlay-backdrop {
// // width: 100%;
// // height: 100%;
// border: 3px solid greenyellow;
// background-color: black;
// min-height: 20px;
// }

View File

@ -2,6 +2,8 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { NgxsModule } from '@ngxs/store';
import { HttpClientModule } from '@angular/common/http';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ContextMenuModule } from 'ngx-contextmenu';
import { CreateStatusComponent } from './create-status.component';
import { WaitingAnimationComponent } from '../waiting-animation/waiting-animation.component';
@ -12,7 +14,7 @@ import { StreamsState } from '../../states/streams.state';
import { NavigationService } from '../../services/navigation.service';
import { NotificationService } from '../../services/notification.service';
import { MastodonService } from '../../services/mastodon.service';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
describe('CreateStatusComponent', () => {
let component: CreateStatusComponent;
@ -26,6 +28,7 @@ describe('CreateStatusComponent', () => {
imports: [
FormsModule,
HttpClientModule,
ContextMenuModule.forRoot(),
NgxsModule.forRoot([
RegisteredAppsState,
AccountsState,
@ -47,6 +50,57 @@ describe('CreateStatusComponent', () => {
expect(component).toBeTruthy();
});
it('should not count emoji as multiple chars', () => {
const status = '😃 😍 👌 👇 😱 😶 status with 😱 😶 emojis 😏 👍 ';
(<any>component).maxCharLength = 500;
(<any>component).countStatusChar(status);
expect((<any>component).charCountLeft).toBe(461);
});
it('should not count emoji in CW as multiple chars', () => {
const status = 'test';
(<any>component).title = '🙂 test';
(<any>component).maxCharLength = 500;
(<any>component).countStatusChar(status);
expect((<any>component).charCountLeft).toBe(490);
});
it('should not count domain chars in username', () => {
const status = 'dsqdqs @NicolasConstant@mastodon.partipirate.org dsqdqsdqsd';
(<any>component).maxCharLength = 500;
(<any>component).countStatusChar(status);
expect((<any>component).charCountLeft).toBe(466);
});
it('should not count https link more than the minimum', () => {
const status = "https://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/";
(<any>component).maxCharLength = 500;
(<any>component).countStatusChar(status);
expect((<any>component).charCountLeft).toBe(477);
});
it('should not count http link more than the minimum', () => {
const status = "http://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/";
(<any>component).maxCharLength = 500;
(<any>component).countStatusChar(status);
expect((<any>component).charCountLeft).toBe(477);
});
it('should not count links more than the minimum', () => {
const status = "http://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/ http://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/ http://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/";
(<any>component).maxCharLength = 500;
(<any>component).countStatusChar(status);
expect((<any>component).charCountLeft).toBe(429);
});
it('should count correctly complex status', () => {
const status = 'dsqdqs @NicolasConstant@mastodon.partipirate.org dsqdqs👇😱 😶 status https://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/ #Pleroma with 😱 😶 emojis 😏 👍 #Mastodon @ddqsdqs @dsqdsq@dqsdsqqdsq';
(<any>component).title = '🙂 test';
(<any>component).maxCharLength = 500;
(<any>component).countStatusChar(status);
expect((<any>component).charCountLeft).toBe(373);
});
it('should not parse small status', () => {
const status = 'this is a cool status';
(<any>component).maxCharLength = 500;
@ -131,4 +185,13 @@ describe('CreateStatusComponent', () => {
expect(result[1]).toContain('@Lorem@ipsum.com ');
});
it('should parse long link properly for multiposting', () => {
const status = 'dsqd qsd qsd sqd qsd sqd qsd sqd qsd qsd dsqd qsd qsd sqd qsd sqd qsd sqd qsd qsd dsqd qsd qsd sqd qsd sqd qsd sqd qsd qsd dsqd qsd qsd sqd qsd sqd qsd sqd qsd qsd dsqd qsd qsd sqd qsd sqd qsd sqd qsd qsd dsqd qsd qsd sqd qsd sqd qsd sqd qsd qsd dsqd qsd qsd sqd qsd sqd qsd sqd qsd qsd dsqd qsd qsd sqd qsd sqd qsd sqd qsd qsd dsqd qsd qsd sqd qsd sqd qsd sqd qsd qsd dsqd qsd qsd sqd qsd sqd qsd sqd qsd qsd dsqd qsd qsd sqd qsd sqd qsd sqd qsd qsd dsqd qsd qsd sqd qsd sqd dsq http://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/';
(<any>component).maxCharLength = 500;
const result = <string[]>(<any>component).parseStatus(status);
expect(result.length).toBe(2);
expect(result[0].length).toBeLessThanOrEqual(527);
expect(result[1].length).toBeLessThanOrEqual(527);
expect(result[1]).toBe('http://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/');
});
});

View File

@ -1,7 +1,11 @@
import { Component, OnInit, OnDestroy, Input, Output, EventEmitter, ElementRef, ViewChild } from '@angular/core';
import { Component, OnInit, OnDestroy, Input, Output, EventEmitter, ElementRef, ViewChild, ViewContainerRef, ComponentRef, HostListener } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Store } from '@ngxs/store';
import { Subscription, Observable } from 'rxjs';
import { UP_ARROW, DOWN_ARROW, ENTER, ESCAPE } from '@angular/cdk/keycodes';
import { faPaperclip, faGlobe, faGlobeAmericas, faLock, faLockOpen, faEnvelope } from "@fortawesome/free-solid-svg-icons";
import { faWindowClose as faWindowCloseRegular } from "@fortawesome/free-regular-svg-icons";
import { ContextMenuService, ContextMenuComponent } from 'ngx-contextmenu';
import { MastodonService, VisibilityEnum } from '../../services/mastodon.service';
import { Status, Attachment } from '../../services/models/mastodon.interfaces';
@ -11,8 +15,10 @@ import { StatusWrapper } from '../../models/common.model';
import { AccountInfo } from '../../states/accounts.state';
import { InstancesInfoService } from '../../services/instances-info.service';
import { MediaService } from '../../services/media.service';
import { identifierModuleUrl } from '@angular/compiler';
import { AutosuggestSelection, AutosuggestUserActionEnum } from './autosuggest/autosuggest.component';
import { Overlay, OverlayConfig, FullscreenOverlayContainer, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal, TemplatePortal } from '@angular/cdk/portal';
import { EmojiPickerComponent } from './emoji-picker/emoji-picker.component';
@Component({
selector: 'app-create-status',
@ -20,6 +26,15 @@ import { identifierModuleUrl } from '@angular/compiler';
styleUrls: ['./create-status.component.scss']
})
export class CreateStatusComponent implements OnInit, OnDestroy {
faPaperclip = faPaperclip;
faGlobe = faGlobe;
faGlobeAmericas = faGlobeAmericas;
faLock = faLock;
faLockOpen = faLockOpen;
faEnvelope = faEnvelope;
autoSuggestUserActionsStream = new EventEmitter<AutosuggestUserActionEnum>();
private _title: string;
set title(value: string) {
this._title = value;
@ -32,10 +47,13 @@ export class CreateStatusComponent implements OnInit, OnDestroy {
private _status: string = '';
@Input('status')
set status(value: string) {
if (value) {
this.countStatusChar(value);
this._status = value;
}
this.countStatusChar(value);
this.detectAutosuggestion(value);
this._status = value;
setTimeout(() => {
this.autoGrow();
}, 0);
}
get status(): string {
return this._status;
@ -44,12 +62,14 @@ export class CreateStatusComponent implements OnInit, OnDestroy {
@Input('redraftedStatus')
set redraftedStatus(value: StatusWrapper) {
if (value) {
this.statusLoaded = false;
let parser = new DOMParser();
var dom = parser.parseFromString(value.status.content, 'text/html')
this.status = dom.body.textContent;
this.setVisibilityFromStatus(value.status);
this.title = value.status.spoiler_text;
this.statusLoaded = true;
if (value.status.in_reply_to_id) {
this.isSending = true;
@ -60,7 +80,7 @@ export class CreateStatusComponent implements OnInit, OnDestroy {
const mentions = this.getMentions(this.statusReplyingToWrapper.status, this.statusReplyingToWrapper.provider);
for (const mention of mentions) {
const name = `@${mention.split('@')[0]}`;
if(this.status.includes(name)){
if (this.status.includes(name)) {
this.status = this.status.replace(name, `@${mention}`);
} else {
this.status = `@${mention} ` + this.status;
@ -83,10 +103,15 @@ export class CreateStatusComponent implements OnInit, OnDestroy {
postCounts: number = 1;
isSending: boolean;
mentionTooFarAwayError: boolean;
autosuggestData: string = null;
private statusLoaded: boolean;
private hasSuggestions: boolean;
@Input() statusReplyingToWrapper: StatusWrapper;
@Output() onClose = new EventEmitter();
@ViewChild('reply') replyElement: ElementRef;
@ViewChild('fileInput') fileInputElement: ElementRef;
@ViewChild(ContextMenuComponent) public contextMenu: ContextMenuComponent;
private _isDirectMention: boolean;
@Input('isDirectMention')
@ -115,19 +140,22 @@ export class CreateStatusComponent implements OnInit, OnDestroy {
private statusReplyingTo: Status;
selectedPrivacy = 'Public';
privacyList: string[] = ['Public', 'Unlisted', 'Follows-only', 'DM'];
// privacyList: string[] = ['Public', 'Unlisted', 'Follows-only', 'DM'];
private accounts$: Observable<AccountInfo[]>;
private accountSub: Subscription;
private selectedAccount: AccountInfo;
constructor(
private readonly contextMenuService: ContextMenuService,
private readonly store: Store,
private readonly notificationService: NotificationService,
private readonly toolsService: ToolsService,
private readonly mastodonService: MastodonService,
private readonly instancesInfoService: InstancesInfoService,
private readonly mediaService: MediaService) {
private readonly mediaService: MediaService,
private readonly overlay: Overlay,
public viewContainerRef: ViewContainerRef) {
this.accounts$ = this.store.select(state => state.registeredaccounts.accounts);
}
@ -156,20 +184,69 @@ export class CreateStatusComponent implements OnInit, OnDestroy {
this.initMention();
}
this.statusLoaded = true;
this.focus();
this.innerHeight = window.innerHeight;
}
ngOnDestroy() {
this.accountSub.unsubscribe();
}
private focus() {
changePrivacy(value: string): boolean {
this.selectedPrivacy = value;
return false;
}
addMedia(): boolean {
this.fileInputElement.nativeElement.click();
return false;
}
handleFileInput(files: File[]): boolean {
const acc = this.toolsService.getSelectedAccounts()[0];
this.mediaService.uploadMedia(acc, files);
return false;
}
private detectAutosuggestion(status: string) {
if (!this.statusLoaded) return;
const caretPosition = this.replyElement.nativeElement.selectionStart;
const word = this.getWordByPos(status, caretPosition);
if (word && word.length > 0 && (word.startsWith('@') || word.startsWith('#'))) {
this.autosuggestData = word;
return;
}
this.autosuggestData = null;
}
private getWordByPos(str, pos) {
var left = str.substr(0, pos);
var right = str.substr(pos);
left = left.replace(/^.+ /g, "");
right = right.replace(/ .+$/g, "");
return left + right;
}
private focus(caretPos = null) {
setTimeout(() => {
this.replyElement.nativeElement.focus();
if (caretPos) {
this.replyElement.nativeElement.setSelectionRange(caretPos, caretPos);
} else {
this.replyElement.nativeElement.setSelectionRange(this.status.length, this.status.length);
}
}, 0);
}
private initMention() {
this.statusLoaded = false;
if (!this.selectedAccount) {
this.selectedAccount = this.toolsService.getSelectedAccounts()[0];
}
@ -182,6 +259,7 @@ export class CreateStatusComponent implements OnInit, OnDestroy {
this.status = `${this.replyingUserHandle} `;
this.countStatusChar(this.status);
this.statusLoaded = true;
this.focus();
}
@ -277,8 +355,9 @@ export class CreateStatusComponent implements OnInit, OnDestroy {
const currentStatus = parseStatus[parseStatus.length - 1];
const statusExtraChars = this.getMentionExtraChars(status);
const linksExtraChars = this.getLinksExtraChars(status);
const statusLength = currentStatus.length - statusExtraChars;
const statusLength = [...currentStatus].length - statusExtraChars - linksExtraChars;
this.charCountLeft = this.maxCharLength - statusLength - this.getCwLength();
this.postCounts = parseStatus.length;
}
@ -286,13 +365,13 @@ export class CreateStatusComponent implements OnInit, OnDestroy {
private getCwLength(): number {
let cwLength = 0;
if (this.title) {
cwLength = this.title.length;
cwLength = [...this.title].length;
}
return cwLength;
}
private getMentions(status: Status, providerInfo: AccountInfo): string[] {
const mentions = [...status.mentions.map(x => x.acct), status.account.acct];
const mentions = [status.account.acct, ...status.mentions.map(x => x.acct)];
let uniqueMentions = [];
for (let mention of mentions) {
@ -428,6 +507,18 @@ export class CreateStatusComponent implements OnInit, OnDestroy {
return results;
}
private getLinksExtraChars(status: string): number {
let mentionExtraChars = 0;
let links = status.split(' ').filter(x => x.startsWith('http://') || x.startsWith('https://'));
for (let link of links) {
if(link.length > 23){
mentionExtraChars += link.length - 23;
}
}
return mentionExtraChars;
}
private getMentionExtraChars(status: string): number {
let mentionExtraChars = 0;
let mentions = this.getMentionsFromStatus(status);
@ -446,4 +537,157 @@ export class CreateStatusComponent implements OnInit, OnDestroy {
private getMentionsFromStatus(status: string): string[] {
return status.split(' ').filter(x => x.indexOf('@') === 0 && x.length > 1);
}
suggestionSelected(selection: AutosuggestSelection) {
if (this.status.includes(selection.pattern)) {
let transformedStatus = this.status;
transformedStatus = transformedStatus.replace(new RegExp(` ${selection.pattern} `), ` ${selection.autosuggest} `).replace(' ', ' ');
transformedStatus = transformedStatus.replace(new RegExp(`${selection.pattern} `), `${selection.autosuggest} `).replace(' ', ' ');
transformedStatus = transformedStatus.replace(new RegExp(`${selection.pattern}$`), `${selection.autosuggest} `).replace(' ', ' ');
this.status = transformedStatus;
let newCaretPosition = this.status.indexOf(`${selection.autosuggest} `) + selection.autosuggest.length + 1;
if (newCaretPosition > this.status.length) newCaretPosition = this.status.length;
this.autosuggestData = null;
this.hasSuggestions = false;
if (document.activeElement === this.replyElement.nativeElement) {
setTimeout(() => {
this.replyElement.nativeElement.setSelectionRange(newCaretPosition, newCaretPosition);
}, 0);
} else {
this.focus(newCaretPosition);
}
}
}
suggestionsChanged(hasSuggestions: boolean) {
this.hasSuggestions = hasSuggestions;
}
handleKeyDown(event: KeyboardEvent): boolean {
if (this.hasSuggestions) {
let keycode = event.keyCode;
if (keycode === DOWN_ARROW || keycode === UP_ARROW || keycode === ENTER || keycode === ESCAPE) {
event.stopImmediatePropagation();
event.preventDefault();
event.stopPropagation();
switch (keycode) {
case DOWN_ARROW:
this.autoSuggestUserActionsStream.next(AutosuggestUserActionEnum.MoveDown);
break;
case UP_ARROW:
this.autoSuggestUserActionsStream.next(AutosuggestUserActionEnum.MoveUp);
break;
case ENTER:
this.autoSuggestUserActionsStream.next(AutosuggestUserActionEnum.Validate);
break;
case ESCAPE:
this.autosuggestData = null;
this.hasSuggestions = false;
break;
}
return false;
}
}
}
statusTextEditorLostFocus(): boolean {
setTimeout(() => {
this.autosuggestData = null;
this.hasSuggestions = false;
}, 250);
return false;
}
private autoGrow() {
let scrolling = (this.replyElement.nativeElement.scrollHeight);
if (scrolling > 110) {
this.replyElement.nativeElement.style.height = `0px`;
this.replyElement.nativeElement.style.height = `${this.replyElement.nativeElement.scrollHeight}px`;
}
}
public onContextMenu($event: MouseEvent): void {
this.contextMenuService.show.next({
// Optional - if unspecified, all context menu components will open
contextMenu: this.contextMenu,
event: $event,
item: null
});
$event.preventDefault();
$event.stopPropagation();
}
//https://stackblitz.com/edit/overlay-demo
@ViewChild('emojiButton') emojiButtonElement: ElementRef;
overlayRef: OverlayRef;
public innerHeight: number;
@HostListener('window:resize', ['$event'])
onResize(event) {
this.innerHeight = window.innerHeight;
}
private emojiCloseSub: Subscription;
private emojiSelectedSub: Subscription;
private beforeEmojiCaretPosition: number;
openEmojiPicker(e: MouseEvent): boolean {
if (this.overlayRef) return false;
this.beforeEmojiCaretPosition = this.replyElement.nativeElement.selectionStart;
let topPosition = e.pageY;
if (this.innerHeight - e.pageY < 360) {
topPosition -= 360;
}
let config = new OverlayConfig();
config.positionStrategy = this.overlay.position()
.global()
.left(`${e.pageX - 283}px`)
.top(`${topPosition}px`);
config.hasBackdrop = true;
this.overlayRef = this.overlay.create(config);
// this.overlayRef.backdropClick().subscribe(() => {
// console.warn('wut?');
// this.overlayRef.dispose();
// });
let comp = new ComponentPortal(EmojiPickerComponent);
const compRef: ComponentRef<EmojiPickerComponent> = this.overlayRef.attach(comp);
this.emojiCloseSub = compRef.instance.closedEvent.subscribe(() => {
this.closeEmojiPanel();
});
this.emojiSelectedSub = compRef.instance.emojiSelectedEvent.subscribe((emoji) => {
if (emoji) {
this.status = [this.status.slice(0, this.beforeEmojiCaretPosition), emoji, ' ', this.status.slice(this.beforeEmojiCaretPosition)].join('').replace(' ', ' ');
this.beforeEmojiCaretPosition += emoji.length + 1;
this.closeEmojiPanel();
}
});
return false;
}
private closeEmojiPanel() {
if (this.emojiCloseSub) this.emojiCloseSub.unsubscribe();
if (this.emojiSelectedSub) this.emojiSelectedSub.unsubscribe();
if (this.overlayRef) this.overlayRef.dispose();
this.overlayRef = null;
this.focus(this.beforeEmojiCaretPosition);
}
closeEmoji(): boolean {
this.overlayRef.dispose();
return false;
}
}

View File

@ -0,0 +1,5 @@
<emoji-mart
*ngIf="loaded"
[showPreview]="false" [perLine]="7" [isNative]="true" [sheetSize]="16" [emojiTooltip]="true"
[custom]="customEmojis" (emojiSelect)="emojiSelected($event)" class="emojipicker" title="Pick your emoji…"
emoji="point_up"></emoji-mart>

View File

@ -0,0 +1,22 @@
::ng-deep .emoji-mart {
border-radius: 0 !important;
font-size: 10px !important;
}
::ng-deep .emoji-mart-emoji-native {
cursor: pointer !important;
}
::ng-deep .emoji-mart-emoji-native span {
position: relative;
top: 1px;
left: -1px;
font-size: 19px !important;
cursor: pointer !important;
}
::ng-deep .emoji-mart-emoji-custom span {
position: relative;
top: 0px;
left: 0px;
}

View File

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

View File

@ -0,0 +1,76 @@
import { Component, OnInit, HostListener, ElementRef, Output, EventEmitter } from '@angular/core';
import { ToolsService } from '../../../services/tools.service';
import { NotificationService } from '../../../services/notification.service';
import { Emoji } from '../../../services/models/mastodon.interfaces';
@Component({
selector: 'app-emoji-picker',
templateUrl: './emoji-picker.component.html',
styleUrls: ['./emoji-picker.component.scss']
})
export class EmojiPickerComponent implements OnInit {
private init = false;
@Output('closed') public closedEvent = new EventEmitter();
@Output('emojiSelected') public emojiSelectedEvent = new EventEmitter<string>();
customEmojis: PickerCustomEmoji[] = [];
loaded: boolean;
constructor(
private notificationService: NotificationService,
private toolsService: ToolsService,
private eRef: ElementRef) { }
@HostListener('document:click', ['$event'])
clickout(event) {
if (!this.init) return;
if (!this.eRef.nativeElement.contains(event.target)) {
this.closedEvent.emit(null);
}
}
ngOnInit() {
let currentAccount = this.toolsService.getSelectedAccounts()[0];
this.toolsService.getCustomEmojis(currentAccount)
.then(emojis => {
console.warn(emojis);
this.customEmojis = emojis.map(x => this.convertEmoji(x));
})
.catch(err => {
this.notificationService.notifyHttpError(err);
})
.then(() => {
this.loaded = true;
});
setTimeout(() => {
this.init = true;
}, 0);
}
private convertEmoji(emoji: Emoji): PickerCustomEmoji {
return new PickerCustomEmoji(emoji.shortcode, [emoji.shortcode], emoji.shortcode, [emoji.shortcode], emoji.url);
}
emojiSelected(select: any): boolean {
if (select.emoji.custom) {
this.emojiSelectedEvent.next(select.emoji.colons);
} else {
this.emojiSelectedEvent.next(select.emoji.native);
}
return false;
}
}
class PickerCustomEmoji {
constructor(
public name: string,
public shortNames: string[],
public text: string,
public keywords: string[],
public imageUrl: string) {
}
}

View File

@ -2,18 +2,33 @@
<div *ngIf="m.attachment === null" class="media__loading" title="{{m.file.name}}">
<app-waiting-animation class="waiting-icon"></app-waiting-animation>
</div>
<div *ngIf="m.attachment !== null" class="media__loaded" title="{{m.file.name}}"
(mouseleave) ="updateMedia(m)">
<div *ngIf="m.attachment !== null && m.attachment.type !== 'audio'" class="media__loaded" title="{{m.file.name}}"
(mouseleave)="updateMedia(m)">
<div class="media__loaded--migrating" *ngIf="m.isMigrating">
<app-waiting-animation class="waiting-icon"></app-waiting-animation>
</div>
<div class="media__loaded--hover">
<button class="media__loaded--button" title="remove" (click)="removeMedia(m)">
<fa-icon [icon]="faTimes"></fa-icon>
<fa-icon [icon]="faTimes"></fa-icon>
</button>
<input class="media__loaded--description" [(ngModel)]="m.description"
autocomplete="off" placeholder="Describe for the visually impaired"/>
<input class="media__loaded--description" [(ngModel)]="m.description" autocomplete="off"
placeholder="Describe for the visually impaired" />
</div>
<img class="media__loaded--preview" src="{{m.attachment.preview_url}}" />
</div>
<div *ngIf="m.attachment !== null && m.attachment.type === 'audio'" class="audio">
<button class="audio__button" title="remove" (click)="removeMedia(m)">
<fa-icon [icon]="faTimes"></fa-icon>
</button>
<div *ngIf="m.isMigrating">
<app-waiting-animation class="waiting-icon"></app-waiting-animation>
</div>
<audio *ngIf="m.audioType && !m.isMigrating" controls class="audio__player">
<source src="{{ m.attachment.url }}" type="{{ m.audioType }}">
Your browser does not support the audio element.
</audio>
</div>
</div>

View File

@ -4,11 +4,11 @@
.media {
width: calc(100%);
padding: 0 5px 5px 5px;
padding: 5px 5px 0px 5px;
&__loading{
width: calc(100%);
border: 1px solid $status-secondary-color;
//border: 1px solid $status-secondary-color;
// background: rgb(0, 96, 134);
overflow: hidden;
padding: 0;
@ -16,7 +16,7 @@
&__loaded{
width: calc(100%);
height: 75px;
border: 1px solid $status-secondary-color;
//border: 1px solid $status-secondary-color;
position: relative;
transition: all .2s;
@ -74,7 +74,29 @@
object-fit: cover;
object-position: 50% 50%;
}
}
}
.audio {
width: calc(100%);
height: 30px;
&__player {
width: calc(100% - 20px);
height: 30px;
}
&__button {
@include clearButton;
display: block;
width: 10px;
height: 10px;
color: white;
float: right;
// position: absolute;
// top:5px;
// right:8px;
}
}

View File

@ -1,8 +1,9 @@
<div class="panel">
<h3 class="panel__title">new message</h3>
<app-create-status (onClose)="closeColumn()"
[isDirectMention]="isDirectMention"
[replyingUserHandle]="userHandle"
[redraftedStatus]="redraftedStatus"></app-create-status>
<div class=" new-message-body flexcroll">
<app-create-status (onClose)="closeColumn()" [isDirectMention]="isDirectMention"
[replyingUserHandle]="userHandle" [redraftedStatus]="redraftedStatus"></app-create-status>
</div>
</div>

View File

@ -1,6 +1,7 @@
@import "variables";
@import "panel";
@import "buttons";
@import "commons";
$btn-send-status-width: 60px;
@ -19,17 +20,18 @@ $btn-send-status-width: 60px;
position: relative;
top: -1px;
left: 5px;
// background-color: orange;
// border-color: orange;
// color: black;
font-weight: 500;
// &:hover {
// }
// &:focus {
// border-color: darkblue;
// }
}
.panel {
padding-left: 0;
padding-right: 0;
}
.new-message-body {
overflow: auto;
height: calc(100% - 30px);
padding-right: 5px;
padding-bottom: 100px;
}

View File

@ -142,7 +142,7 @@ class AttachmentsWrapper implements Attachment {
}
id: string;
type: "image" | "video" | "gifv";
type: "image" | "video" | "gifv" | 'audio';
url: string;
remote_url: string;
preview_url: string;

View File

@ -2,12 +2,12 @@ import { Injectable } from '@angular/core';
import { HttpHeaders, HttpClient, HttpResponse } from '@angular/common/http';
import { ApiRoutes } from './models/api.settings';
import { Account, Status, Results, Context, Relationship, Instance, Attachment, Notification, List, Poll } from "./models/mastodon.interfaces";
import { Account, Status, Results, Context, Relationship, Instance, Attachment, Notification, List, Poll, Emoji } from "./models/mastodon.interfaces";
import { AccountInfo } from '../states/accounts.state';
import { StreamTypeEnum, StreamElement } from '../states/streams.state';
@Injectable()
export class MastodonService {
export class MastodonService {
private apiRoutes = new ApiRoutes();
constructor(private readonly httpClient: HttpClient) { }
@ -367,6 +367,11 @@ export class MastodonService {
let route = `https://${account.instance}${this.apiRoutes.deleteStatus}`.replace('{0}', statusId.toString());
const headers = new HttpHeaders({ 'Authorization': `Bearer ${account.token.access_token}` });
return this.httpClient.delete<any>(route, { headers: headers }).toPromise();
}
getCustomEmojis(account: AccountInfo): Promise<Emoji[]> {
let route = `https://${account.instance}${this.apiRoutes.getCustomEmojis}`;
return this.httpClient.get<Emoji[]>(route).toPromise();
}
}

View File

@ -29,7 +29,7 @@ export class MediaService {
let medias = this.mediaSubject.value;
medias.push(wrapper);
if(medias.length > 4){
if (medias.length > 4) {
medias.splice(0, 1);
}
this.mediaSubject.next(medias);
@ -104,8 +104,26 @@ export class MediaWrapper {
constructor(
public id: string,
public file: File,
public attachment: Attachment) { }
attachment: Attachment) {
this.attachment = attachment;
}
private _attachment: Attachment;
public get attachment(): Attachment {
return this._attachment;
}
public set attachment(value: Attachment){
if (value && value.meta && value.meta.audio_encode) {
this.audioType = `audio/${value.meta.audio_encode}`;
} else if (value && value.pleroma && value.pleroma.mime_type) {
this.audioType = value.pleroma.mime_type;
}
this._attachment = value;
}
public description: string;
public isMigrating: boolean;
public audioType: string;
}

View File

@ -3,6 +3,7 @@ export class ApiRoutes {
createApp = '/api/v1/apps';
getToken = '/oauth/token';
getAccount = '/api/v1/accounts/{0}';
getCustomEmojis = '/api/v1/custom_emojis';
getCurrentAccount = '/api/v1/accounts/verify_credentials';
getAccountFollowers = '/api/v1/accounts/{0}/followers';
getAccountFollowing = '/api/v1/accounts/{0}/following';

View File

@ -71,7 +71,7 @@ export interface Application {
export interface Attachment {
id: string;
type: 'image' | 'video' | 'gifv';
type: 'image' | 'video' | 'gifv' | 'audio';
url: string;
remote_url: string;
preview_url: string;

View File

@ -3,7 +3,7 @@ import { Store } from '@ngxs/store';
import { AccountInfo } from '../states/accounts.state';
import { MastodonService } from './mastodon.service';
import { Account, Results, Status } from "./models/mastodon.interfaces";
import { Account, Results, Status, Emoji } from "./models/mastodon.interfaces";
import { StatusWrapper } from '../models/common.model';
import { AccountSettings, SaveAccountSettings } from '../states/settings.state';
@ -20,23 +20,23 @@ export class ToolsService {
var regAccounts = <AccountInfo[]>this.store.snapshot().registeredaccounts.accounts;
return regAccounts.filter(x => x.isSelected);
}
getAccountSettings(account: AccountInfo): AccountSettings {
var accountsSettings = <AccountSettings[]>this.store.snapshot().globalsettings.settings.accountSettings;
let accountSettings = accountsSettings.find(x => x.accountId === account.id);
if(!accountSettings){
if (!accountSettings) {
accountSettings = new AccountSettings();
accountSettings.accountId = account.id;
this.saveAccountSettings(accountSettings);
this.saveAccountSettings(accountSettings);
}
if(!accountSettings.customStatusCharLength){
if (!accountSettings.customStatusCharLength) {
accountSettings.customStatusCharLength = 500;
this.saveAccountSettings(accountSettings);
}
return accountSettings;
}
saveAccountSettings(accountSettings: AccountSettings){
saveAccountSettings(accountSettings: AccountSettings) {
this.store.dispatch([
new SaveAccountSettings(accountSettings)
])
@ -45,19 +45,19 @@ export class ToolsService {
findAccount(account: AccountInfo, accountName: string): Promise<Account> {
return this.mastodonService.search(account, accountName, true)
.then((result: Results) => {
if(accountName[0] === '@') accountName = accountName.substr(1);
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])
);
||
(x.acct.toLowerCase().split('@')[0] === accountName.toLowerCase().split('@')[0])
&& x.url.replace('https://', '').split('/')[0] === accountName.toLowerCase().split('@')[1])
);
return foundAccount;
});
}
getStatusUsableByAccount(account: AccountInfo, originalStatus: StatusWrapper): Promise<Status>{
getStatusUsableByAccount(account: AccountInfo, originalStatus: StatusWrapper): Promise<Status> {
const isProvider = originalStatus.provider.id === account.id;
let statusPromise: Promise<Status> = Promise.resolve(originalStatus.status);
@ -82,6 +82,19 @@ export class ToolsService {
}
return `@${fullHandle}`;
}
private emojiCache: { [id: string]: Emoji[] } = {};
getCustomEmojis(account: AccountInfo): Promise<Emoji[]> {
if (this.emojiCache[account.id]) {
return Promise.resolve(this.emojiCache[account.id]);
} else {
return this.mastodonService.getCustomEmojis(account)
.then(emojis => {
this.emojiCache[account.id] = emojis.filter(x => x.visible_in_picker);
return this.emojiCache[account.id];
});
}
}
}
export class OpenThreadEvent {

View File

@ -1,16 +1,19 @@
@import "variables";
::ng-deep .ngx-contextmenu {
box-shadow: 0 0 10px rgba(0, 0, 0, 0.6);
-moz-box-shadow: 0 0 10px rgba(0, 0, 0, 0.6);
-webkit-box-shadow: 0 0 10px rgba(0, 0, 0, 0.6);
-o-box-shadow: 0 0 10px rgba(0, 0, 0, 0.6);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
-moz-box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
-webkit-box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
-o-box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
& .dropdown-menu {
border: solid 1px $context-menu-border-color;
//border: solid 1px $context-menu-border-color;
border: none;
background-color: $context-menu-background;
padding: 2px 0;
border-radius: 2px;
padding: 0;
border-radius: 0px;
// padding: 2px 0;
// border-radius: 2px;
//border: solid 2px $context-menu-border-color;
}
@ -31,7 +34,7 @@
padding: 0.5em 1em;
padding: 3px 10px;
text-decoration: none;
transition: all .2s;
//transition: all .2s;
&:hover {
color: $context-menu-font-color;

View File

@ -59,4 +59,26 @@ $context-menu-background-hover: #a9c9e6;
$context-menu-font-color: #000000;
$context-menu-border-color: #c0cdd9;
$direct-message-background: #090a0f;
$direct-message-background: #090a0f;
$status-editor-title-background: #a9c9e6;
$status-editor-title-background: #ebebeb;
$status-editor-background: #d9e1e8;
$status-editor-background: #ffffff;
$status-editor-color: #23252c;
$status-editor-color: #14151a;
$status-editor-footer-background: #535c7e;
$status-editor-footer-background: #3e455f;
$status-editor-footer-link-color: #e2e2e2;
$autosuggest-background: #ffffff;
$autosuggest-entry-background: #3e455f;
$autosuggest-entry-background: #0f111a;
$autosuggest-entry-color: #62667a;
$autosuggest-entry-handle-color: #e2e2e2;
$autosuggest-entry-background-hover: #3e455f;
$autosuggest-entry-background-hover: rgb(37, 41, 56);
$autosuggest-entry-background-hover: rgb(46, 51, 70);
$autosuggest-entry-color-hover: #e2e2e2;
$autosuggest-entry-handle-color-hover: #ffffff;

View File

@ -9,6 +9,7 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es5",
"downlevelIteration": true,
"typeRoots": [
"node_modules/@types"
],