Adding autocomplete to post, community, message, and site forms. Fixes #453

pull/722/head
Dessalines 2020-01-24 13:59:50 -05:00
parent 353891a3d1
commit 608856b6cd
6 changed files with 193 additions and 136 deletions

View File

@ -2,28 +2,20 @@ import { Component, linkEvent } from 'inferno';
import {
CommentNode as CommentNodeI,
CommentForm as CommentFormI,
SearchForm,
SearchType,
SortType,
UserOperation,
SearchResponse,
} from '../interfaces';
import { Subscription } from 'rxjs';
import {
wsJsonToRes,
capitalizeFirstLetter,
mentionDropdownFetchLimit,
mdToHtml,
randomStr,
markdownHelpUrl,
toast,
setupTribute,
} from '../utils';
import { WebSocketService, UserService } from '../services';
import autosize from 'autosize';
import Tribute from 'tributejs/src/Tribute.js';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
import Tribute from 'tributejs/src/Tribute.js';
import emojiShortName from 'emoji-short-name';
interface CommentFormProps {
postId?: number;
@ -42,9 +34,7 @@ interface CommentFormState {
export class CommentForm extends Component<CommentFormProps, CommentFormState> {
private id = `comment-form-${randomStr()}`;
private userSub: Subscription;
private communitySub: Subscription;
private tribute: any;
private tribute: Tribute;
private emptyState: CommentFormState = {
commentForm: {
auth: null,
@ -68,55 +58,7 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
constructor(props: any, context: any) {
super(props, context);
this.tribute = new Tribute({
collection: [
// Emojis
{
trigger: ':',
menuItemTemplate: (item: any) => {
let emoji = `:${item.original.key}:`;
return `${item.original.val} ${emoji}`;
},
selectTemplate: (item: any) => {
return `:${item.original.key}:`;
},
values: Object.entries(emojiShortName).map(e => {
return { key: e[1], val: e[0] };
}),
allowSpaces: false,
autocompleteMode: true,
menuItemLimit: mentionDropdownFetchLimit,
},
// Users
{
trigger: '@',
selectTemplate: (item: any) => {
return `[/u/${item.original.key}](/u/${item.original.key})`;
},
values: (text: string, cb: any) => {
this.userSearch(text, (users: any) => cb(users));
},
allowSpaces: false,
autocompleteMode: true,
menuItemLimit: mentionDropdownFetchLimit,
},
// Communities
{
trigger: '#',
selectTemplate: (item: any) => {
return `[/c/${item.original.key}](/c/${item.original.key})`;
},
values: (text: string, cb: any) => {
this.communitySearch(text, (communities: any) => cb(communities));
},
allowSpaces: false,
autocompleteMode: true,
menuItemLimit: mentionDropdownFetchLimit,
},
],
});
this.tribute = setupTribute();
this.state = this.emptyState;
if (this.props.node) {
@ -297,68 +239,4 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
toast(error, 'danger');
});
}
userSearch(text: string, cb: any) {
if (text) {
let form: SearchForm = {
q: text,
type_: SearchType[SearchType.Users],
sort: SortType[SortType.TopAll],
page: 1,
limit: mentionDropdownFetchLimit,
};
WebSocketService.Instance.search(form);
this.userSub = WebSocketService.Instance.subject.subscribe(
msg => {
let res = wsJsonToRes(msg);
if (res.op == UserOperation.Search) {
let data = res.data as SearchResponse;
let users = data.users.map(u => {
return { key: u.name };
});
cb(users);
this.userSub.unsubscribe();
}
},
err => console.error(err),
() => console.log('complete')
);
} else {
cb([]);
}
}
communitySearch(text: string, cb: any) {
if (text) {
let form: SearchForm = {
q: text,
type_: SearchType[SearchType.Communities],
sort: SortType[SortType.TopAll],
page: 1,
limit: mentionDropdownFetchLimit,
};
WebSocketService.Instance.search(form);
this.communitySub = WebSocketService.Instance.subject.subscribe(
msg => {
let res = wsJsonToRes(msg);
if (res.op == UserOperation.Search) {
let data = res.data as SearchResponse;
let communities = data.communities.map(u => {
return { key: u.name };
});
cb(communities);
this.communitySub.unsubscribe();
}
},
err => console.error(err),
() => console.log('complete')
);
} else {
cb([]);
}
}
}

View File

@ -11,7 +11,14 @@ import {
WebSocketJsonResponse,
} from '../interfaces';
import { WebSocketService } from '../services';
import { wsJsonToRes, capitalizeFirstLetter, toast } from '../utils';
import {
wsJsonToRes,
capitalizeFirstLetter,
toast,
randomStr,
setupTribute,
} from '../utils';
import Tribute from 'tributejs/src/Tribute.js';
import autosize from 'autosize';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
@ -36,6 +43,8 @@ export class CommunityForm extends Component<
CommunityFormProps,
CommunityFormState
> {
private id = `community-form-${randomStr()}`;
private tribute: Tribute;
private subscription: Subscription;
private emptyState: CommunityFormState = {
@ -53,6 +62,7 @@ export class CommunityForm extends Component<
constructor(props: any, context: any) {
super(props, context);
this.tribute = setupTribute();
this.state = this.emptyState;
if (this.props.community) {
@ -80,7 +90,14 @@ export class CommunityForm extends Component<
}
componentDidMount() {
autosize(document.querySelectorAll('textarea'));
var textarea: any = document.getElementById(this.id);
autosize(textarea);
this.tribute.attach(textarea);
textarea.addEventListener('tribute-replaced', () => {
this.state.communityForm.description = textarea.value;
this.setState(this.state);
autosize.update(textarea);
});
}
componentWillUnmount() {
@ -130,6 +147,7 @@ export class CommunityForm extends Component<
</label>
<div class="col-12">
<textarea
id={this.id}
value={this.state.communityForm.description}
onInput={linkEvent(this, this.handleCommunityDescriptionChange)}
class="form-control"

View File

@ -30,8 +30,11 @@ import {
debounce,
isImage,
toast,
randomStr,
setupTribute,
} from '../utils';
import autosize from 'autosize';
import Tribute from 'tributejs/src/Tribute.js';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
@ -56,6 +59,8 @@ interface PostFormState {
}
export class PostForm extends Component<PostFormProps, PostFormState> {
private id = `post-form-${randomStr()}`;
private tribute: Tribute;
private subscription: Subscription;
private emptyState: PostFormState = {
postForm: {
@ -82,6 +87,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
this.fetchSimilarPosts = debounce(this.fetchSimilarPosts).bind(this);
this.fetchPageTitle = debounce(this.fetchPageTitle).bind(this);
this.tribute = setupTribute();
this.state = this.emptyState;
if (this.props.post) {
@ -126,7 +132,14 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
}
componentDidMount() {
autosize(document.querySelectorAll('textarea'));
var textarea: any = document.getElementById(this.id);
autosize(textarea);
this.tribute.attach(textarea);
textarea.addEventListener('tribute-replaced', () => {
this.state.postForm.body = textarea.value;
this.setState(this.state);
autosize.update(textarea);
});
}
componentWillUnmount() {
@ -238,6 +251,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
</label>
<div class="col-sm-10">
<textarea
id={this.id}
value={this.state.postForm.body}
onInput={linkEvent(this, this.handlePostBodyChange)}
className={`form-control ${this.state.previewMode && 'd-none'}`}

View File

@ -24,7 +24,10 @@ import {
pictshareAvatarThumbnail,
wsJsonToRes,
toast,
randomStr,
setupTribute,
} from '../utils';
import Tribute from 'tributejs/src/Tribute.js';
import autosize from 'autosize';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
@ -49,6 +52,8 @@ export class PrivateMessageForm extends Component<
PrivateMessageFormProps,
PrivateMessageFormState
> {
private id = `message-form-${randomStr()}`;
private tribute: Tribute;
private subscription: Subscription;
private emptyState: PrivateMessageFormState = {
privateMessageForm: {
@ -64,6 +69,7 @@ export class PrivateMessageForm extends Component<
constructor(props: any, context: any) {
super(props, context);
this.tribute = setupTribute();
this.state = this.emptyState;
if (this.props.privateMessage) {
@ -93,7 +99,14 @@ export class PrivateMessageForm extends Component<
}
componentDidMount() {
autosize(document.querySelectorAll('textarea'));
var textarea: any = document.getElementById(this.id);
autosize(textarea);
this.tribute.attach(textarea);
textarea.addEventListener('tribute-replaced', () => {
this.state.privateMessageForm.content = textarea.value;
this.setState(this.state);
autosize.update(textarea);
});
}
componentWillUnmount() {
@ -136,6 +149,7 @@ export class PrivateMessageForm extends Component<
<label class="col-sm-2 col-form-label">{i18n.t('message')}</label>
<div class="col-sm-10">
<textarea
id={this.id}
value={this.state.privateMessageForm.content}
onInput={linkEvent(this, this.handleContentChange)}
className={`form-control ${this.state.previewMode && 'd-none'}`}

View File

@ -1,8 +1,9 @@
import { Component, linkEvent } from 'inferno';
import { Site, SiteForm as SiteFormI } from '../interfaces';
import { WebSocketService } from '../services';
import { capitalizeFirstLetter } from '../utils';
import { capitalizeFirstLetter, randomStr, setupTribute } from '../utils';
import autosize from 'autosize';
import Tribute from 'tributejs/src/Tribute.js';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
@ -17,6 +18,8 @@ interface SiteFormState {
}
export class SiteForm extends Component<SiteFormProps, SiteFormState> {
private id = `site-form-${randomStr()}`;
private tribute: Tribute;
private emptyState: SiteFormState = {
siteForm: {
enable_downvotes: true,
@ -29,7 +32,10 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
constructor(props: any, context: any) {
super(props, context);
this.tribute = setupTribute();
this.state = this.emptyState;
if (this.props.site) {
this.state.siteForm = {
name: this.props.site.name,
@ -42,7 +48,14 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
}
componentDidMount() {
autosize(document.querySelectorAll('textarea'));
var textarea: any = document.getElementById(this.id);
autosize(textarea);
this.tribute.attach(textarea);
textarea.addEventListener('tribute-replaced', () => {
this.state.siteForm.description = textarea.value;
this.setState(this.state);
autosize.update(textarea);
});
}
render() {
@ -75,6 +88,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
</label>
<div class="col-12">
<textarea
id={this.id}
value={this.state.siteForm.description}
onInput={linkEvent(this, this.handleSiteDescriptionChange)}
class="form-control"

127
ui/src/utils.ts vendored
View File

@ -18,13 +18,17 @@ import {
SearchType,
WebSocketResponse,
WebSocketJsonResponse,
SearchForm,
SearchResponse,
} from './interfaces';
import { UserService } from './services/UserService';
import { UserService, WebSocketService } from './services';
import Tribute from 'tributejs/src/Tribute.js';
import markdown_it from 'markdown-it';
import markdownitEmoji from 'markdown-it-emoji/light';
import markdown_it_container from 'markdown-it-container';
import * as twemoji from 'twemoji';
import * as emojiShortName from 'emoji-short-name';
import twemoji from 'twemoji';
import emojiShortName from 'emoji-short-name';
import Toastify from 'toastify-js';
export const repoUrl = 'https://github.com/dessalines/lemmy';
@ -33,7 +37,7 @@ export const archiveUrl = 'https://archive.is';
export const postRefetchSeconds: number = 60 * 1000;
export const fetchLimit: number = 20;
export const mentionDropdownFetchLimit = 6;
export const mentionDropdownFetchLimit = 10;
export function randomStr() {
return Math.random()
@ -380,3 +384,118 @@ export function toast(text: string, background: string = 'success') {
backgroundColor: backgroundColor,
}).showToast();
}
export function setupTribute(): Tribute {
return new Tribute({
collection: [
// Emojis
{
trigger: ':',
menuItemTemplate: (item: any) => {
let emoji = `:${item.original.key}:`;
return `${item.original.val} ${emoji}`;
},
selectTemplate: (item: any) => {
return `:${item.original.key}:`;
},
values: Object.entries(emojiShortName).map(e => {
return { key: e[1], val: e[0] };
}),
allowSpaces: false,
autocompleteMode: true,
menuItemLimit: mentionDropdownFetchLimit,
},
// Users
{
trigger: '@',
selectTemplate: (item: any) => {
return `[/u/${item.original.key}](/u/${item.original.key})`;
},
values: (text: string, cb: any) => {
userSearch(text, (users: any) => cb(users));
},
allowSpaces: false,
autocompleteMode: true,
menuItemLimit: mentionDropdownFetchLimit,
},
// Communities
{
trigger: '#',
selectTemplate: (item: any) => {
return `[/c/${item.original.key}](/c/${item.original.key})`;
},
values: (text: string, cb: any) => {
communitySearch(text, (communities: any) => cb(communities));
},
allowSpaces: false,
autocompleteMode: true,
menuItemLimit: mentionDropdownFetchLimit,
},
],
});
}
function userSearch(text: string, cb: any) {
if (text) {
let form: SearchForm = {
q: text,
type_: SearchType[SearchType.Users],
sort: SortType[SortType.TopAll],
page: 1,
limit: mentionDropdownFetchLimit,
};
WebSocketService.Instance.search(form);
this.userSub = WebSocketService.Instance.subject.subscribe(
msg => {
let res = wsJsonToRes(msg);
if (res.op == UserOperation.Search) {
let data = res.data as SearchResponse;
let users = data.users.map(u => {
return { key: u.name };
});
cb(users);
this.userSub.unsubscribe();
}
},
err => console.error(err),
() => console.log('complete')
);
} else {
cb([]);
}
}
function communitySearch(text: string, cb: any) {
if (text) {
let form: SearchForm = {
q: text,
type_: SearchType[SearchType.Communities],
sort: SortType[SortType.TopAll],
page: 1,
limit: mentionDropdownFetchLimit,
};
WebSocketService.Instance.search(form);
this.communitySub = WebSocketService.Instance.subject.subscribe(
msg => {
let res = wsJsonToRes(msg);
if (res.op == UserOperation.Search) {
let data = res.data as SearchResponse;
let communities = data.communities.map(u => {
return { key: u.name };
});
cb(communities);
this.communitySub.unsubscribe();
}
},
err => console.error(err),
() => console.log('complete')
);
} else {
cb([]);
}
}