diff options
author | sualko <klaus@jsxc.org> | 2022-01-02 19:17:16 +0300 |
---|---|---|
committer | sualko <klaus@jsxc.org> | 2022-01-02 19:17:16 +0300 |
commit | be92eb22be7865ef61894f41a73409a7a1399fd3 (patch) | |
tree | 0bdb361d096616a40a5d7e1acfc5e1861a82edaa | |
parent | 789d4b07052128b92d13a1bf04e52db3b7f29ea0 (diff) |
feat: add chat message context menu
-rw-r--r-- | fonts/jsxc-icons.eot | bin | 11572 -> 11752 bytes | |||
-rw-r--r-- | fonts/jsxc-icons.scss | 18 | ||||
-rw-r--r-- | fonts/jsxc-icons.woff | bin | 6272 -> 6372 bytes | |||
-rw-r--r-- | fonts/jsxc-icons.woff2 | bin | 5192 -> 5260 bytes | |||
-rw-r--r-- | images/icons/quotation.svg | 60 | ||||
-rw-r--r-- | scss/partials/_menu.scss | 27 | ||||
-rw-r--r-- | scss/partials/_window.scss | 153 | ||||
-rw-r--r-- | src/Account.ts | 11 | ||||
-rw-r--r-- | src/Menu.ts | 19 | ||||
-rw-r--r-- | src/MenuChatMessage.ts | 22 | ||||
-rw-r--r-- | src/MenuItemFactory.interface.ts | 11 | ||||
-rw-r--r-- | src/MenuItemStaticFactory.ts | 19 | ||||
-rw-r--r-- | src/ui/ChatWindow.ts | 2 | ||||
-rw-r--r-- | src/ui/ChatWindowMessage.ts | 20 | ||||
-rw-r--r-- | src/ui/MenuComponent.ts | 127 | ||||
-rw-r--r-- | src/ui/util/LongPress.ts | 32 | ||||
-rw-r--r-- | template/menu.hbs | 7 |
17 files changed, 459 insertions, 69 deletions
diff --git a/fonts/jsxc-icons.eot b/fonts/jsxc-icons.eot Binary files differindex f8314df0..429e5b2d 100644 --- a/fonts/jsxc-icons.eot +++ b/fonts/jsxc-icons.eot diff --git a/fonts/jsxc-icons.scss b/fonts/jsxc-icons.scss index c3be0b55..89d4e8b3 100644 --- a/fonts/jsxc-icons.scss +++ b/fonts/jsxc-icons.scss @@ -2,9 +2,9 @@ $jsxc-icons-font: "jsxc-icons"; @font-face { font-family: $jsxc-icons-font; - src: url("../fonts/jsxc-icons.eot?1a3d16709418122adaef6fedd3d0c8fe#iefix") format("embedded-opentype"), -url("../fonts/jsxc-icons.woff2?1a3d16709418122adaef6fedd3d0c8fe") format("woff2"), -url("../fonts/jsxc-icons.woff?1a3d16709418122adaef6fedd3d0c8fe") format("woff"); + src: url("../fonts/jsxc-icons.eot?d795960dd0062a30f89bb22c3b0df55e#iefix") format("embedded-opentype"), +url("../fonts/jsxc-icons.woff2?d795960dd0062a30f89bb22c3b0df55e") format("woff2"), +url("../fonts/jsxc-icons.woff?d795960dd0062a30f89bb22c3b0df55e") format("woff"); } [class^="jsxc-icon-"]:before, [class*=" jsxc-icon-"]:before { @@ -56,10 +56,11 @@ $jsxc-icons-map: ( "pick-up-disabled": "\f123", "pick-up": "\f124", "placeholder": "\f125", - "resize": "\f126", - "search": "\f127", - "smiley": "\f128", - "speech-balloon": "\f129", + "quotation": "\f126", + "resize": "\f127", + "search": "\f128", + "smiley": "\f129", + "speech-balloon": "\f12a", ); .jsxc-icon-attachment:before { @@ -173,6 +174,9 @@ $jsxc-icons-map: ( .jsxc-icon-placeholder:before { content: map-get($jsxc-icons-map, "placeholder"); } +.jsxc-icon-quotation:before { + content: map-get($jsxc-icons-map, "quotation"); +} .jsxc-icon-resize:before { content: map-get($jsxc-icons-map, "resize"); } diff --git a/fonts/jsxc-icons.woff b/fonts/jsxc-icons.woff Binary files differindex 3c29036b..3c9379d6 100644 --- a/fonts/jsxc-icons.woff +++ b/fonts/jsxc-icons.woff diff --git a/fonts/jsxc-icons.woff2 b/fonts/jsxc-icons.woff2 Binary files differindex df7ebe09..4784938e 100644 --- a/fonts/jsxc-icons.woff2 +++ b/fonts/jsxc-icons.woff2 diff --git a/images/icons/quotation.svg b/images/icons/quotation.svg new file mode 100644 index 00000000..7a7b98c5 --- /dev/null +++ b/images/icons/quotation.svg @@ -0,0 +1,60 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="44" + height="44" + version="1.1" + id="svg4" + sodipodi:docname="quotation.svg" + inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"> + <metadata + id="metadata10"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + </cc:Work> + </rdf:RDF> + </metadata> + <defs + id="defs8" /> + <sodipodi:namedview + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1" + objecttolerance="10" + gridtolerance="10" + guidetolerance="10" + inkscape:pageopacity="0" + inkscape:pageshadow="2" + inkscape:window-width="1999" + inkscape:window-height="1265" + id="namedview6" + showgrid="false" + inkscape:zoom="5.3636364" + inkscape:cx="-5.5" + inkscape:cy="22" + inkscape:window-x="1562" + inkscape:window-y="280" + inkscape:window-maximized="0" + inkscape:current-layer="svg4" /> + <g + aria-label=""" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:17.33333397px;line-height:125%;font-family:'Helvetica Neue';-inkscape-font-specification:'Helvetica Neue';text-align:start;letter-spacing:0px;word-spacing:0px;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + id="text819" + transform="matrix(7.8234615,0,0,7.8234615,-143.85981,-87.522429)"> + <path + d="m 19.579639,16.321897 0.277334,-0.208 0.849333,-2.426667 0.052,-0.624 -0.06933,-0.485333 -0.208,-0.329333 -0.502667,-0.398667 -0.398667,-0.138667 h -0.294666 l -0.416,0.138667 -0.06933,0.173333 -0.138666,0.08667 -0.104,0.225333 -0.138667,0.814667 0.173333,0.208 0.364,0.208 0.416,0.416 0.173334,0.988 z m 3.224,-0.03467 0.277334,-0.208 0.849333,-2.426666 0.052,-0.624 -0.06933,-0.485334 -0.208,-0.329333 -0.502667,-0.398667 -0.398667,-0.138666 h -0.294666 l -0.416,0.138666 -0.06933,0.173334 -0.138666,0.08667 -0.104,0.225334 -0.138667,0.814666 0.173333,0.208 0.364,0.208 0.416,0.416 0.173334,0.988 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Bocadillo;-inkscape-font-specification:Bocadillo" + id="path821" + inkscape:connector-curvature="0" /> + </g> +</svg> diff --git a/scss/partials/_menu.scss b/scss/partials/_menu.scss index 51e38173..1b98fc26 100644 --- a/scss/partials/_menu.scss +++ b/scss/partials/_menu.scss @@ -4,11 +4,13 @@ &__button { align-items: flex-start; + color: "inherit"; cursor: pointer; display: flex; flex-direction: column; height: 100%; justify-content: center; + text-decoration: none; user-select: none; white-space: nowrap; @@ -65,7 +67,8 @@ &--drop-bottom-left, &--drop-bottom-right, &--drop-right-top, - &--vertical-left { + &--vertical-left + &--vertical-right { .jsxc-menu__content { border-radius: 3px; max-height: 9999px; @@ -133,6 +136,28 @@ } } + &--vertical-right { + .jsxc-menu__content { + left: 100%; + top: 0; + + &::after { + right: 100%; + top: 14px; + transform: rotate(90deg); + } + } + + ul { + display: flex; + padding: 0; + } + + li { + height: 42px; + } + } + &--drop-bottom-left { .jsxc-menu__content { bottom: 100%; diff --git a/scss/partials/_window.scss b/scss/partials/_window.scss index 82ff23e4..334f76ce 100644 --- a/scss/partials/_window.scss +++ b/scss/partials/_window.scss @@ -346,82 +346,113 @@ } } - p { - clear: both; - font-size: 1em; - margin: 0; - - +p { - margin-top: 0.7em; + .jsxc-content { + p { + clear: both; + font-size: 1em; + margin: 0; + + +p { + margin-top: 0.7em; + } } - } - - a { - color: $chatmessage-a; - display: inline-block; - max-width: 100%; - position: relative; - text-decoration: underline; - &[download]::before { - background-color: rgba(255, 255, 255, 0.7); - background-image: url("../images/download_icon_black.svg"); - background-position: center center; - background-repeat: no-repeat; - background-size: 3em; - border-radius: 3px; - bottom: 5px; - content: " "; - left: 0; - opacity: 0; - position: absolute; - right: 0; - top: 0; - transition: opacity 0.5s; - } + a { + color: $chatmessage-a; + display: inline-block; + max-width: 100%; + position: relative; + text-decoration: underline; - &[download]:hover { - &::before { - opacity: 0.6; + &[download]::before { + background-color: rgba(255, 255, 255, 0.7); + background-image: url("../images/download_icon_black.svg"); + background-position: center center; + background-repeat: no-repeat; + background-size: 3em; + border-radius: 3px; + bottom: 5px; + content: " "; + left: 0; + opacity: 0; + position: absolute; + right: 0; + top: 0; + transition: opacity 0.5s; } - } - &.jsxc-geo { - background-color: #fff; - border-radius: 0.8em; - display: block; - padding: 1em 1em 1em 3em; + &[download]:hover { + &::before { + opacity: 0.6; + } + } - &::before { + &.jsxc-geo { background-color: #fff; - background-image: url("../images/location_icon.svg"); - background-position: center center; - background-repeat: no-repeat; - background-size: contain; - content: ""; - display: inline-block; - height: 2em; - margin-left: -2em; - vertical-align: bottom; - width: 2em; + border-radius: 0.8em; + display: block; + padding: 1em 1em 1em 3em; + + &::before { + background-color: #fff; + background-image: url("../images/location_icon.svg"); + background-position: center center; + background-repeat: no-repeat; + background-size: contain; + content: ""; + display: inline-block; + height: 2em; + margin-left: -2em; + vertical-align: bottom; + width: 2em; + } } } + + img { + max-width: 100%; + } + + .jsxc-avatar { + display: none; + } + + .jsxc-quote { + border-left: 3px solid #999; + display: inline-block; + opacity: 0.7; + padding: 0 3px 0 5px; + width: 100%; // margin-bottom: 5px; + } } - img { - max-width: 100%; + &:hover .jsxc-menu__button { + visibility: visible !important; } - .jsxc-avatar { + .jsxc-menu { display: none; - } + position: absolute; + top: 0; - .jsxc-quote { - border-left: 3px solid #999; - display: inline-block; - opacity: 0.7; - padding: 0 3px 0 5px; - width: 100%; // margin-bottom: 5px; + .jsxc-menu__button { + height: 42px; + width: 42px; + } + + &:not(.jsxc-menu--opened) .jsxc-menu__button { + visibility: hidden; + } + + &.jsxc-menu--vertical-right { + display: block; + right: 100%; + } + + &.jsxc-menu--vertical-left { + display: block; + left: 100%; + } } } diff --git a/src/Account.ts b/src/Account.ts index 6a007ec9..ba9aa61e 100644 --- a/src/Account.ts +++ b/src/Account.ts @@ -25,6 +25,7 @@ import CommandRepository from './CommandRepository'; import AvatarSet from '@ui/AvatarSet'; import { IAvatar } from './Avatar.interface'; import CallManager from './CallManager'; +import MenuChatMessage from './MenuChatMessage'; type ConnectionCallback = (status: number, condition?: string) => void; @@ -59,6 +60,8 @@ export default class Account { private callManager: CallManager; + private chatMessageMenu: MenuChatMessage; + private options: Options; private pipes = {}; @@ -262,6 +265,14 @@ export default class Account { return this.callManager; } + public getChatMessageMenu(): MenuChatMessage { + if (!this.chatMessageMenu) { + this.chatMessageMenu = new MenuChatMessage(); + } + + return this.chatMessageMenu; + } + public getContact(jid?: IJID): IContact { return jid && jid.bare !== this.getJID().bare ? this.getContactManager().getContact(jid) : this.contact; } diff --git a/src/Menu.ts b/src/Menu.ts new file mode 100644 index 00000000..a2c50be7 --- /dev/null +++ b/src/Menu.ts @@ -0,0 +1,19 @@ +import IMenuItemFactory, { MenuItem } from './MenuItemFactory.interface'; + +export default class Menu<Params extends any[] = any[]> { + constructor(private menuItems: IMenuItemFactory<Params>[] = []) {} + + public getMenuItems(...args: Params): MenuItem[] { + return this.menuItems + .map(menuItem => menuItem.generate(...args)) + .filter(menuItem => menuItem !== false) as MenuItem[]; + } + + public registerMenuItem(menuItem: IMenuItemFactory<Params>) { + this.menuItems.push(menuItem); + } + + public removeMenuItem(menuItem: IMenuItemFactory<Params>) { + this.menuItems = this.menuItems.filter(item => item !== menuItem); + } +} diff --git a/src/MenuChatMessage.ts b/src/MenuChatMessage.ts new file mode 100644 index 00000000..1f72ea4a --- /dev/null +++ b/src/MenuChatMessage.ts @@ -0,0 +1,22 @@ +import { IContact } from './Contact.interface'; +import Menu from './Menu'; +import MenuItemStaticFactory from './MenuItemStaticFactory'; +import { IMessage } from './Message.interface'; + +function quoteMessage(contact: IContact, message: IMessage) { + const chatWindow = contact.getChatWindow(); + const inputText = chatWindow.getInput(); + const quote = message + .getPlaintextMessage() + .split('\n') + .map(line => '> ' + line) + .join('\n'); + + chatWindow.setInput(inputText + (!inputText || inputText.endsWith('\n\n') ? '' : '\n\n') + quote + '\n\n'); +} + +export default class MenuChatMessage extends Menu<[IContact, IMessage]> { + constructor() { + super([new MenuItemStaticFactory('core-quote', '', quoteMessage, 'quotation')]); + } +} diff --git a/src/MenuItemFactory.interface.ts b/src/MenuItemFactory.interface.ts new file mode 100644 index 00000000..0d8f8b70 --- /dev/null +++ b/src/MenuItemFactory.interface.ts @@ -0,0 +1,11 @@ +export type MenuItem = { + id: string; + label: string; + icon?: string; + disabled?: boolean; + handler: (ev: Event) => void; +}; + +export default interface IMenuItemFactory<Params extends any[]> { + generate: (...args: Params) => MenuItem | false; +} diff --git a/src/MenuItemStaticFactory.ts b/src/MenuItemStaticFactory.ts new file mode 100644 index 00000000..cbd44959 --- /dev/null +++ b/src/MenuItemStaticFactory.ts @@ -0,0 +1,19 @@ +import IMenuItemFactory, { MenuItem } from './MenuItemFactory.interface'; + +export default class MenuItemStaticFactory<Params extends any[] = any[]> implements IMenuItemFactory<Params> { + constructor( + private id: string, + private label: string, + private handler: (...args: Params) => void, + private icon?: string + ) {} + + public generate(...args: Params): MenuItem { + return { + id: this.id, + label: this.label, + handler: () => this.handler(...args), + icon: this.icon, + }; + } +} diff --git a/src/ui/ChatWindow.ts b/src/ui/ChatWindow.ts index ba25c670..a3d6e429 100644 --- a/src/ui/ChatWindow.ts +++ b/src/ui/ChatWindow.ts @@ -190,6 +190,8 @@ export default class ChatWindow { public setInput(text: string) { this.inputElement.val(text); this.inputElement.trigger('focus'); + + this.resizeInputArea(); } public appendTextToInput(text: string = '') { diff --git a/src/ui/ChatWindowMessage.ts b/src/ui/ChatWindowMessage.ts index 5bafa4ab..60538dd5 100644 --- a/src/ui/ChatWindowMessage.ts +++ b/src/ui/ChatWindowMessage.ts @@ -7,6 +7,8 @@ import LinkHandlerGeo from '@src/LinkHandlerGeo'; import Color from '@util/Color'; import Translation from '@util/Translation'; import messageHistory from './dialogs/messageHistory'; +import MenuComponent from './MenuComponent'; +import onLongPress from './util/LongPress'; let chatWindowMessageTemplate = require('../../template/chat-window-message.hbs'); @@ -27,6 +29,8 @@ export default class ChatWindowMessage { this.generateElement(); this.registerHooks(); + + this.initMenu(); } public getElement() { @@ -237,6 +241,22 @@ export default class ChatWindowMessage { } } + private initMenu() { + if (this.message.isSystem()) { + return; + } + + const messageMenu = this.chatWindow.getAccount().getChatMessageMenu(); + const menuType = this.message.isOutgoing() ? 'vertical-right' : 'vertical-left'; + const menu = new MenuComponent('more', menuType, messageMenu, [this.chatWindow.getContact(), this.message]); + + this.element.append(menu.getElement()); + + onLongPress(this.element, () => { + menu.toggle(); + }); + } + private registerHooks() { this.message.registerHook('replacedBy', () => { const chatWindowMessageReplacement = new ChatWindowMessage(this.originalMessage, this.chatWindow); diff --git a/src/ui/MenuComponent.ts b/src/ui/MenuComponent.ts new file mode 100644 index 00000000..c3112ed0 --- /dev/null +++ b/src/ui/MenuComponent.ts @@ -0,0 +1,127 @@ +import Menu from '@src/Menu'; + +const CLASSNAME_OPENED = 'jsxc-menu--opened'; +const menuTemplate = require('../../template/menu.hbs'); + +export default class MenuComponent<Params extends unknown[]> { + private element: JQuery<HTMLElement>; + + private timer: number; + + constructor( + label: string | { text: string; icon: string }, + type: 'pushup' | 'vertical-left' | 'vertical-right', + private menu: Menu<Params>, + private params: Params, + theme: 'dark' | 'light' = 'light' + ) { + this.element = $( + menuTemplate({ + classes: `jsxc-menu--${type} jsxc-menu--${theme}`, + labelText: typeof label === 'object' ? label.text : '', + }) + ); + + const icon = typeof label === 'string' ? label : label.icon; + + if (icon) { + this.element.find('.jsxc-menu__button').prepend($('<i>').addClass(`jsxc-icon-${icon} jsxc-icon--center`)); + } + + this.registerHandlers(); + } + + public getElement(): JQuery<HTMLElement> { + return this.element; + } + + public getButtonElement(): JQuery { + return this.element.find('.jsxc-menu__button'); + } + + public toggle(): void { + if (this.element.hasClass(CLASSNAME_OPENED)) { + this.closeMenu(); + } else { + this.openMenu(); + } + } + + private addEntry( + label: string, + handler: (ev: JQuery.ClickEvent) => void, + icon?: string, + disabled?: boolean + ): JQuery { + let itemElement = $('<li>'); + + if (disabled) { + itemElement.addClass('jsxc-disabled'); + } + + itemElement.text(label); + itemElement.on('click', handler); + + if (icon) { + itemElement.prepend($('<i>').addClass(`jsxc-icon-${icon} jsxc-icon--center`)); + } + + this.element.find('ul').append(itemElement); + + return itemElement; + } + + private clearEntries() { + this.element.find('ul').empty(); + } + + private registerHandlers() { + this.element.on('click', this.onClick); + this.element.on('mouseleave', this.onMouseLeave); + this.element.on('mouseenter', this.onMouseEnter); + } + + private onClick = (ev: JQuery.ClickEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + + this.toggle(); + }; + + private onMouseLeave = () => { + if (this.element.hasClass(CLASSNAME_OPENED)) { + this.timer = window.setTimeout(this.closeMenu, 2000); + } + }; + + private onMouseEnter = () => { + window.clearTimeout(this.timer); + }; + + private openMenu = () => { + this.clearEntries(); + + this.menu.getMenuItems(...this.params).map(({ label, handler, disabled, icon }) => { + this.addEntry(label, (ev: JQuery.ClickEvent) => handler(ev.originalEvent), icon, disabled); + }); + + $('body').off('click', null, this.closeMenu); + + // hide other lists + $('body').trigger('click'); + + window.clearTimeout(this.timer); + + this.element.addClass(CLASSNAME_OPENED); + + $('body').on('click', this.closeMenu); + }; + + private closeMenu = () => { + this.element.removeClass(CLASSNAME_OPENED); + + this.clearEntries(); + + $('body').off('click', null, this.closeMenu); + }; +} diff --git a/src/ui/util/LongPress.ts b/src/ui/util/LongPress.ts new file mode 100644 index 00000000..166aa18d --- /dev/null +++ b/src/ui/util/LongPress.ts @@ -0,0 +1,32 @@ +const onLongPress = (element: JQuery<HTMLElement>, handler: () => void): void => { + const duration = 1000; + + let durationTimeout: number; + let isLongPress: boolean; + + element.on('mousedown', ev => { + isLongPress = false; + + durationTimeout = window.setTimeout(() => { + ev.stopPropagation(); + ev.preventDefault(); + + isLongPress = true; + + handler(); + }, duration); + }); + + element.on('mouseup', () => { + window.clearTimeout(durationTimeout); + }); + + element.on('click', ev => { + if (isLongPress) { + ev.stopPropagation(); + ev.preventDefault(); + } + }); +}; + +export default onLongPress; diff --git a/template/menu.hbs b/template/menu.hbs new file mode 100644 index 00000000..b1335628 --- /dev/null +++ b/template/menu.hbs @@ -0,0 +1,7 @@ +<div class="jsxc-menu {{classes}}"> + <a class="jsxc-menu__button jsxc-icon--clickable">{{label-text}}</a> + <div class="jsxc-menu__content"> + <ul> + </ul> + </div> +</div> |