diff options
author | Klaus <klaus@jsxc.org> | 2022-01-02 19:57:08 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-01-02 19:57:08 +0300 |
commit | 3259aae6d7e27d45d2f95a9e17566396603e0f10 (patch) | |
tree | c3fdbcba147720d1f2c3d34ff8679d1d93bbadfd | |
parent | c63aa6823c54fa45027200f653d9802ec63c3798 (diff) | |
parent | bcdb0adefda85029e5f63cd5421467d6e868126e (diff) |
Merge pull request #1066 from jsxc/feat-message-correction
Last Message Correction (XEP-0308)
35 files changed, 906 insertions, 123 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 7eea2cb5..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%; + } } } @@ -526,6 +557,23 @@ } } +.jsxc-chatmessage.jsxc-edited { + .jsxc-version::before { + @extend %jsxc-icon-font; + content: map-get($jsxc-icons-map, "edit"); + cursor: pointer; + } +} + +ul.jsxc-message-history { + padding: 0.5em 0 2em; + width: 250px; + + .jsxc-version { + display: none; + } +} + .jsxc-mark::before { height: 1em; diff --git a/src/Account.ts b/src/Account.ts index 8ebacf5b..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; @@ -51,7 +52,7 @@ export default class Account { private ownDiscoInfo: DiscoInfoChangeable; - private hookRepository = new HookRepository<any>(); + private hookRepository = new HookRepository(); private contactManager: ContactManager; @@ -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/CommandRepository.ts b/src/CommandRepository.ts index 90148d7d..97c3753b 100644 --- a/src/CommandRepository.ts +++ b/src/CommandRepository.ts @@ -13,7 +13,11 @@ export class ArgumentError extends Error { } } -export type CommandAction = (args: string[], contact: IContact | MultiUserContact) => Promise<boolean>; +export type CommandAction = ( + args: string[], + contact: IContact | MultiUserContact, + message?: string +) => Promise<boolean>; export default class CommandRepository { private commands: { @@ -47,7 +51,7 @@ export default class CommandRepository { return Promise.resolve(false); } - return this.commands[command].action(args, contact); + return this.commands[command].action(args, contact, message); } public getHelp() { diff --git a/src/Contact.ts b/src/Contact.ts index 12a415d8..a77d5b64 100644 --- a/src/Contact.ts +++ b/src/Contact.ts @@ -15,6 +15,7 @@ import ChatWindow from './ui/ChatWindow'; import ContactProvider from './ContactProvider'; import DiscoInfo from './DiscoInfo'; import { IJID } from './JID.interface'; +import { IMessage } from './Message.interface'; export default class Contact implements IIdentifiable, IContact { protected storage: Storage; @@ -111,7 +112,7 @@ export default class Contact implements IIdentifiable, IContact { return this.account; } - public addSystemMessage(messageString: string): Message { + public addSystemMessage(messageString: string): IMessage { let message = new Message({ peer: this.getJid(), direction: Message.DIRECTION.SYS, 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/Message.interface.ts b/src/Message.interface.ts index 2a44ece8..ef5e7483 100644 --- a/src/Message.interface.ts +++ b/src/Message.interface.ts @@ -43,6 +43,8 @@ export interface IMessagePayload { chatMarkersReceived?: boolean; chatMarkersDisplayed?: boolean; chatMarkersAcknowledged?: boolean; + replacedBy?: string; + original?: string; } export interface IMessage { @@ -145,4 +147,16 @@ export interface IMessage { getErrorMessage(): string; updateProgress(transferred: number, complete: number); + + getLastVersion(): IMessage; + + getReplacedBy(): IMessage; + + setReplacedBy(message: IMessage): void; + + getOriginal(): IMessage; + + setOriginal(message: IMessage): void; + + isReplacement(): boolean; } diff --git a/src/Message.ts b/src/Message.ts index a66272d9..e3ab0d7d 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -54,6 +54,10 @@ export default class Message implements IIdentifiable, IMessage { private attachment: Attachment; + private replacedBy: IMessage; + + private original: IMessage; + public static readonly DIRECTION = DIRECTION; public static readonly MSGTYPE = ContactType; @@ -367,6 +371,52 @@ export default class Message implements IIdentifiable, IMessage { public updateProgress(transferred: number, size: number) { this.data.set('progress', transferred / size); } + + public getLastVersion(): IMessage { + let replacedBy = this.getReplacedBy(); + + while (replacedBy && replacedBy.getReplacedBy()) { + replacedBy = replacedBy.getReplacedBy(); + } + + return replacedBy || this; + } + + public getReplacedBy(): IMessage { + if (this.replacedBy) { + return this.replacedBy; + } + + const replacedByUid = this.data.get('replacedBy'); + + this.replacedBy = replacedByUid ? new Message(replacedByUid) : undefined; + + return this.replacedBy; + } + + public setReplacedBy(message: IMessage): void { + this.data.set('replacedBy', message.getUid()); + } + + public getOriginal(): IMessage { + if (this.original) { + return this.original; + } + + const originalUid = this.data.get('original'); + + this.original = originalUid ? new Message(originalUid) : undefined; + + return this.original; + } + + public setOriginal(message: IMessage): void { + this.data.set('original', message.getUid()); + } + + public isReplacement(): boolean { + return !!this.data.get('original'); + } } function convertUrlToLink(text: string) { diff --git a/src/Transcript.ts b/src/Transcript.ts index dcb9d883..cf00f29d 100644 --- a/src/Transcript.ts +++ b/src/Transcript.ts @@ -55,7 +55,23 @@ export default class Transcript { public getFirstChatMessage(): IMessage { for (let message of this.getGenerator()) { - if (!message.isSystem()) { + if (!message.isSystem() && !message.isReplacement()) { + return message; + } + } + } + + public getFirstIncomingMessage(): IMessage { + for (let message of this.getGenerator()) { + if (message.isIncoming() && !message.isReplacement()) { + return message; + } + } + } + + public getFirstOutgoingMessage(): IMessage { + for (let message of this.getGenerator()) { + if (message.isOutgoing() && !message.isReplacement()) { return message; } } @@ -69,6 +85,14 @@ export default class Transcript { return this.firstMessage; } + public getFirstOriginalMessage(): IMessage { + for (let message of this.getGenerator()) { + if (!message.isReplacement()) { + return message; + } + } + } + public getLastMessage(): IMessage { if (this.lastMessage) { return this.lastMessage; diff --git a/src/bootstrap/plugins.ts b/src/bootstrap/plugins.ts index 933ef013..2ad52c77 100644 --- a/src/bootstrap/plugins.ts +++ b/src/bootstrap/plugins.ts @@ -17,7 +17,8 @@ import CommandPlugin from '@src/plugins/CommandPlugin'; import VersionPlugin from '@src/plugins/VersionPlugin'; import TimePlugin from '../plugins/TimePlugin'; import JingleMessageInitiationPlugin from '../plugins/JingleMessageInitiationPlugin'; -import AvatarPEPPlugin from '../plugins/AvatarPEPPlugin'; +import AvatarPEPPlugin from '@src/plugins/AvatarPEPPlugin'; +import LastMessageCorrectionPlugin from '@src/plugins/LastMessageCorrectionPlugin'; Client.addPlugin(OTRPlugin); Client.addPlugin(OMEMOPlugin); @@ -38,3 +39,4 @@ Client.addPlugin(VersionPlugin); Client.addPlugin(TimePlugin); Client.addPlugin(JingleMessageInitiationPlugin); Client.addPlugin(AvatarPEPPlugin); +Client.addPlugin(LastMessageCorrectionPlugin); diff --git a/src/connection/AbstractConnection.ts b/src/connection/AbstractConnection.ts index 38df8323..1e4eacf9 100644 --- a/src/connection/AbstractConnection.ts +++ b/src/connection/AbstractConnection.ts @@ -149,11 +149,13 @@ abstract class AbstractConnection { }) .c('body', { xmlns: Strophe.NS.XHTML, - }) - .cnode($(htmlMessage).get(0)) - .up() - .up() - .up(); + }); + + for (const node of $(htmlMessage).get()) { + xmlMsg.cnode(node).up(); + } + + xmlMsg.up().up(); } let plaintextMessage = this.getMessage( @@ -202,7 +204,7 @@ abstract class AbstractConnection { }); } - private getMessage(message: Message, getEncryptedMessage: () => string, getMessage: () => string) { + private getMessage(message: Message, getEncryptedMessage: () => string, getMessage: () => string): string { if (message.isEncrypted() && getEncryptedMessage.call(message)) { return getEncryptedMessage.call(message); } else if (getMessage.call(message)) { diff --git a/src/connection/xmpp/MessageElement.ts b/src/connection/xmpp/MessageElement.ts index a4cd902c..933e37a7 100644 --- a/src/connection/xmpp/MessageElement.ts +++ b/src/connection/xmpp/MessageElement.ts @@ -71,7 +71,7 @@ export class MessageElement { return this.element.find(selector); } - public get(index?) { + public get(index?: number) { return this.element.get(index); } diff --git a/src/connection/xmpp/handlers/chatMessage.ts b/src/connection/xmpp/handlers/chatMessage.ts index 6b3b670b..d95fdec0 100644 --- a/src/connection/xmpp/handlers/chatMessage.ts +++ b/src/connection/xmpp/handlers/chatMessage.ts @@ -51,6 +51,10 @@ export default class extends AbstractHandler { let pipe = this.account.getPipe('afterReceiveMessage'); pipe.run(peerContact, message, messageElement.get(0)).then(([contact, message]) => { + if (!message) { + return; + } + if (message.getPlaintextMessage() || message.getHtmlMessage() || message.hasAttachment()) { contact.getTranscript().pushMessage(message); } else { diff --git a/src/plugin/PluginAPI.interface.ts b/src/plugin/PluginAPI.interface.ts index b65caddd..d9bb1bd7 100644 --- a/src/plugin/PluginAPI.interface.ts +++ b/src/plugin/PluginAPI.interface.ts @@ -12,6 +12,7 @@ import Pipe from '@util/Pipe'; import CommandRepository, { CommandAction } from '@src/CommandRepository'; import IStorage from '@src/Storage.interface'; import CallManager from '@src/CallManager'; +import IMenuItemFactory from '@src/MenuItemFactory.interface'; export interface IPluginAPI { Log: ILog; @@ -105,4 +106,6 @@ export interface IPluginAPI { getAccountUid(): string; getCallManager(): CallManager; + + registerChatMessageMenuItem(menuItem: IMenuItemFactory<[IContact, IMessage]>): void; } diff --git a/src/plugin/PluginAPI.ts b/src/plugin/PluginAPI.ts index f5faa6c9..bf332910 100644 --- a/src/plugin/PluginAPI.ts +++ b/src/plugin/PluginAPI.ts @@ -18,6 +18,7 @@ import IStorage from '@src/Storage.interface'; import CommandRepository, { CommandAction } from '@src/CommandRepository'; import { IAvatar } from '@src/Avatar.interface'; import CallManager from '@src/CallManager'; +import IMenuItemFactory from '@src/MenuItemFactory.interface'; export default class PluginAPI implements IPluginAPI { private storage: IStorage; @@ -120,7 +121,7 @@ export default class PluginAPI implements IPluginAPI { } public addPreSendMessageStanzaProcessor( - processor: (message: Message, xmlMsg: Strophe.Builder) => Promise<[Message, Strophe.Builder]>, + processor: (message: IMessage, xmlMsg: Strophe.Builder) => Promise<[IMessage, Strophe.Builder]>, position?: number ) { this.account.getPipe('preSendMessageStanza').addProcessor(processor, position); @@ -234,4 +235,8 @@ export default class PluginAPI implements IPluginAPI { public getCallManager(): CallManager { return this.account.getCallManager(); } + + public registerChatMessageMenuItem(menuItem: IMenuItemFactory<[IContact, IMessage]>): void { + this.account.getChatMessageMenu().registerMenuItem(menuItem); + } } diff --git a/src/plugins/LastMessageCorrectionPlugin.ts b/src/plugins/LastMessageCorrectionPlugin.ts new file mode 100644 index 00000000..c3db4c46 --- /dev/null +++ b/src/plugins/LastMessageCorrectionPlugin.ts @@ -0,0 +1,185 @@ +import { AbstractPlugin, IMetaData } from '../plugin/AbstractPlugin'; +import PluginAPI from '../plugin/PluginAPI'; +import Translation from '@util/Translation'; +import * as Namespace from '@connection/xmpp/namespace'; +import { IContact } from '@src/Contact.interface'; +import { IMessage } from '@src/Message.interface'; +import Message from '@src/Message'; + +/** + * XEP-0308: Last Message Correction + * + * @version: 1.2.0 + * @see: https://xmpp.org/extensions/xep-0308.html + * + */ + +const CORRECTION_CMD = '/fix'; +const LMC = 'urn:xmpp:message-correct:0'; + +const MIN_VERSION = '4.3.0'; +const MAX_VERSION = '99.0.0'; + +Namespace.register('LAST_MSG_CORRECTION', LMC); + +export default class LastMessageCorrectionPlugin extends AbstractPlugin { + public static getId(): string { + return 'lmc'; + } + + public static getName(): string { + return 'Last Message Correction'; + } + + public static getMetaData(): IMetaData { + return { + description: Translation.t('setting-lmc-enable'), + xeps: [ + { + id: 'XEP-0308', + name: 'Last Message Correction', + version: '1.2.0', + }, + ], + }; + } + + private correctionRequests: { [contactUid: string]: IMessage } = {}; + + constructor(pluginAPI: PluginAPI) { + super(MIN_VERSION, MAX_VERSION, pluginAPI); + + pluginAPI.registerCommand(CORRECTION_CMD, this.commandHandler, 'cmd_correction'); + + pluginAPI.addPreSendMessageStanzaProcessor(this.addReplaceElementToStanza, 90); + + pluginAPI.addAfterReceiveMessageProcessor(this.checkMessageCorrection, 90); + + pluginAPI.registerChatMessageMenuItem({ + generate: this.generateCorrectMessageMenuItem, + }); + } + + private commandHandler = async (args: string[], contact: IContact, messageString: string) => { + const originalMessage = contact.getTranscript().getFirstOutgoingMessage(); + + this.correctionRequests[contact.getUid()] = originalMessage; + + if (!originalMessage || !contact.isChat()) { + return false; + } + + const chatWindow = contact.getChatWindow(); + const message = new Message({ + peer: contact.getJid(), + direction: Message.DIRECTION.OUT, + type: contact.getType(), + plaintextMessage: messageString.replace(/^\/fix /, ''), + attachment: chatWindow.getAttachment(), + unread: false, + original: originalMessage.getUid(), + }); + + contact.getTranscript().pushMessage(message); + + chatWindow.clearAttachment(); + + let pipe = contact.getAccount().getPipe('preSendMessage'); + + return pipe + .run(contact, message) + .then(([contact, message]) => { + originalMessage.getLastVersion().setReplacedBy(message); + + contact.getAccount().getConnection().sendMessage(message); + + return true; + }) + .catch(err => { + this.pluginAPI.Log.warn('Error during preSendMessage pipe', err); + + return false; + }); + }; + + private addReplaceElementToStanza = async ( + message: IMessage, + xmlMsg: Strophe.Builder + ): Promise<[IMessage, Strophe.Builder]> => { + const contact = this.pluginAPI.getContact(message.getPeer()); + const originalMessage = this.correctionRequests[contact.getUid()]; + + if (!originalMessage || originalMessage.getLastVersion().getUid() !== message.getUid()) { + return [message, xmlMsg]; + } + + delete this.correctionRequests[contact.getUid()]; + + xmlMsg + .c('replace', { + xmlns: LMC, + id: originalMessage.getAttrId(), + }) + .up(); + + return [message, xmlMsg]; + }; + + private checkMessageCorrection = async ( + contact: IContact, + message: IMessage, + stanza: Element + ): Promise<[IContact, IMessage, Element]> => { + const replaceElement = $(stanza).find(`>replace[xmlns="${LMC}"]`); + + if (replaceElement.length === 0) { + return [contact, message, stanza]; + } + + const attrIdToBeReplaced = replaceElement.attr('id'); + + if (!attrIdToBeReplaced) { + return [contact, message, stanza]; + } + + const firstIncomingMessage = contact.getTranscript().getFirstIncomingMessage(); + + if (firstIncomingMessage && firstIncomingMessage.getAttrId() === attrIdToBeReplaced) { + message.setOriginal(firstIncomingMessage); + + firstIncomingMessage.getLastVersion().setReplacedBy(message); + } + + return [contact, message, stanza]; + }; + + private generateCorrectMessageMenuItem = (contact: IContact, message: IMessage) => { + if (message.isOutgoing()) { + const lastOutgoingMessage = contact.getTranscript().getFirstOutgoingMessage().getLastVersion(); + + if (lastOutgoingMessage.getUid() === message.getUid()) { + const chatWindow = contact.getChatWindow(); + + return { + id: 'lmc-edit', + label: '', + icon: 'edit', + handler: () => { + let plaintextMessage = message.getPlaintextMessage(); + + if (message.hasAttachment()) { + const attachment = message.getAttachment(); + + chatWindow.setAttachment(attachment); + plaintextMessage = plaintextMessage.replace(attachment.getData() + '\n', ''); + } + + chatWindow.setInput(CORRECTION_CMD + ' ' + plaintextMessage); + }, + }; + } + } + + return false; + }; +} diff --git a/src/plugins/NotificationPlugin.ts b/src/plugins/NotificationPlugin.ts index 471fc1b7..1ba6cccc 100644 --- a/src/plugins/NotificationPlugin.ts +++ b/src/plugins/NotificationPlugin.ts @@ -1,11 +1,11 @@ -import Message from '../Message'; import { AbstractPlugin } from '../plugin/AbstractPlugin'; import PluginAPI from '../plugin/PluginAPI'; -import Contact from '../Contact'; import Translation from '../util/Translation'; import Notification from '../Notification'; import { Presence } from '../connection/AbstractConnection'; import { SOUNDS } from '../CONST'; +import { IContact } from '@src/Contact.interface'; +import { IMessage } from '@src/Message.interface'; const MIN_VERSION = '4.0.0'; const MAX_VERSION = '99.0.0'; @@ -32,7 +32,11 @@ export default class NotificationPlugin extends AbstractPlugin { pluginAPI.registerPresenceHook(this.onPresence); } - private afterReceiveMessageProcessor = (contact: Contact, message: Message): Promise<any> => { + private afterReceiveMessageProcessor = ( + contact: IContact, + message: IMessage, + stanza: Element + ): Promise<[IContact, IMessage, Element]> => { if ((message.getPlaintextMessage() || message.getAttachment()) && message.isIncoming()) { Notification.notify({ title: Translation.t('New_message_from', { @@ -44,10 +48,10 @@ export default class NotificationPlugin extends AbstractPlugin { }); } - return Promise.resolve([contact, message]); + return Promise.resolve([contact, message, stanza]); }; - private onPresence = (contact: Contact, newPresence, oldPresence) => { + private onPresence = (contact: IContact, newPresence, oldPresence) => { if (oldPresence !== Presence.offline || newPresence === Presence.offline) { return; } diff --git a/src/ui/ChatWindow.ts b/src/ui/ChatWindow.ts index ce03e97e..a3d6e429 100644 --- a/src/ui/ChatWindow.ts +++ b/src/ui/ChatWindow.ts @@ -183,6 +183,17 @@ export default class ChatWindow { this.element.find('.jsxc-bar__caption__secondary').text(text); } + public getInput(): string { + return this.inputElement.val().toString(); + } + + public setInput(text: string) { + this.inputElement.val(text); + this.inputElement.trigger('focus'); + + this.resizeInputArea(); + } + public appendTextToInput(text: string = '') { let value = this.inputElement.val(); @@ -228,6 +239,10 @@ export default class ChatWindow { return this.settingsMenu.addEntry(label, cb, className); } + public getAttachment(): Attachment { + return this.attachmentDeposition; + } + public setAttachment(attachment: Attachment) { this.attachmentDeposition = attachment; @@ -627,7 +642,7 @@ export default class ChatWindow { } private restoreLocalHistory() { - let firstMessage = this.getTranscript().getFirstMessage(); + let firstMessage = this.getTranscript().getFirstOriginalMessage(); if (!firstMessage) { return; @@ -668,7 +683,9 @@ export default class ChatWindow { let message = this.getTranscript().getMessage(firstMessageId); - this.postMessage(message); + if (!message.isReplacement()) { + this.postMessage(message); + } }); } diff --git a/src/ui/ChatWindowMessage.ts b/src/ui/ChatWindowMessage.ts index 3e646a3a..60538dd5 100644 --- a/src/ui/ChatWindowMessage.ts +++ b/src/ui/ChatWindowMessage.ts @@ -6,15 +6,31 @@ import Log from '../util/Log'; 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'); export default class ChatWindowMessage { - private element; + private element: JQuery<HTMLElement>; + + private message: IMessage; + + constructor(private originalMessage: IMessage, private chatWindow: ChatWindow, useLastVersion: boolean = true) { + this.message = useLastVersion ? originalMessage.getLastVersion() : originalMessage; + + let template = chatWindowMessageTemplate({ + id: this.message.getCssId(), + direction: this.message.getDirectionString(), + }); + + this.element = $(template); - constructor(private message: IMessage, private chatWindow: ChatWindow) { this.generateElement(); this.registerHooks(); + + this.initMenu(); } public getElement() { @@ -36,35 +52,30 @@ export default class ChatWindowMessage { } private getNextMessage() { - let nextId = this.message.getNextId(); + let nextId = this.originalMessage.getNextId(); - if (!nextId) { - return; - } + while (nextId) { + let nextMessage = this.chatWindow.getTranscript().getMessage(nextId); - let nextMessage = this.chatWindow.getTranscript().getMessage(nextId); + if (!nextMessage) { + Log.warn('Couldnt find next message.'); + return; + } - if (!nextMessage) { - Log.warn('Couldnt find next message.'); - return; - } + if (!nextMessage.isReplacement()) { + return nextMessage; + } - return nextMessage; + nextId = nextMessage.getNextId(); + } } private async generateElement() { - let template = chatWindowMessageTemplate({ - id: this.message.getCssId(), - direction: this.message.getDirectionString(), - }); - - this.element = $(template); - let bodyElement = $(await this.message.getProcessedBody()); LinkHandlerGeo.get().detect(bodyElement); - this.element.find('.jsxc-content').html(bodyElement); + this.element.find('.jsxc-content').html(bodyElement.get(0)); let timestampElement = this.element.find('.jsxc-timestamp'); DateTime.stringify(this.message.getStamp().getTime(), timestampElement); @@ -85,6 +96,18 @@ export default class ChatWindowMessage { this.element.addClass('jsxc-unread'); } + if (this.message.isReplacement()) { + this.element.addClass('jsxc-edited'); + } + + this.element.find('.jsxc-version').on('click', () => { + if (!this.element.hasClass('jsxc-edited')) { + return; + } + + messageHistory(this.originalMessage, this.chatWindow); + }); + if (this.message.getErrorMessage()) { this.element.addClass('jsxc-error'); this.element.find('.jsxc-error-content').text(Translation.t(this.message.getErrorMessage())); @@ -218,7 +241,29 @@ 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); + + this.element.replaceWith(chatWindowMessageReplacement.getElement()); + }); + this.message.registerHook('encrypted', encrypted => { if (encrypted) { this.element.addClass('jsxc-encrypted'); 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/dialogs/messageHistory.ts b/src/ui/dialogs/messageHistory.ts new file mode 100644 index 00000000..e2652e79 --- /dev/null +++ b/src/ui/dialogs/messageHistory.ts @@ -0,0 +1,22 @@ +import Dialog from '../Dialog'; +import { IMessage } from '@src/Message.interface'; +import ChatWindowMessage from '@ui/ChatWindowMessage'; +import ChatWindow from '@ui/ChatWindow'; + +const messageHistoryTemplate = require('../../../template/messageHistory.hbs'); + +export default function messageHistory(message: IMessage, chatWindow: ChatWindow) { + const content = messageHistoryTemplate({}); + + const dialog = new Dialog(content); + const dom = dialog.open(); + + while (message) { + const messageElement = new ChatWindowMessage(message, chatWindow, false); + + dom.find('ul').append('<li>'); + dom.find('ul > li:last-child').append(messageElement.getElement()); + + message = message.getReplacedBy(); + } +} 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/src/util/HookRepository.ts b/src/util/HookRepository.ts index e9415970..965a79d8 100644 --- a/src/util/HookRepository.ts +++ b/src/util/HookRepository.ts @@ -1,7 +1,7 @@ -export default class HookRepository<T extends Function> { +export default class HookRepository<Args extends any[] = any[]> { private hooks = {}; - public registerHook(eventName: string, func: T) { + public registerHook(eventName: string, func: (...args: Args) => void) { if (!this.hooks[eventName]) { this.hooks[eventName] = []; } @@ -9,7 +9,7 @@ export default class HookRepository<T extends Function> { this.hooks[eventName].push(func); } - public removeHook(eventName: string, func: T) { + public removeHook(eventName: string, func: (...args: Args) => void) { let eventNameList = this.hooks[eventName] || []; if (eventNameList.indexOf(func) > -1) { @@ -21,7 +21,7 @@ export default class HookRepository<T extends Function> { this.hooks[eventName] = eventNameList; } - public trigger(targetEventName: string, ...args) { + public trigger(targetEventName: string, ...args: Args) { let hooks = this.hooks; let eventNames = Object.keys(hooks); diff --git a/src/util/Pipe.ts b/src/util/Pipe.ts index 359bba6a..95dad395 100644 --- a/src/util/Pipe.ts +++ b/src/util/Pipe.ts @@ -1,14 +1,14 @@ +import Log from './Log'; + const MAX_PRIORITY = 100; const MIN_PRIORITY = 0; -type Params = any[]; - -export default class Pipe<params extends Params = any[]> { - private pipe = []; +export default class Pipe<Params extends any[] = any[]> { + private pipe: ((...args: Params) => Promise<Params> | Params)[][] = []; constructor() {} - public addProcessor(processor: (...args: params) => Promise<params> | params, priority: number = 50) { + public addProcessor(processor: (...args: Params) => Promise<Params> | Params, priority: number = 50) { if (isNaN(priority) || priority < MIN_PRIORITY || priority > MAX_PRIORITY) { throw new Error('Priority has to be between 0 and 100'); } @@ -20,7 +20,7 @@ export default class Pipe<params extends Params = any[]> { this.pipe[priority].push(processor); } - public run(...args: params): Promise<params> { + public run(...args: Params): Promise<Params> { let chain = Promise.resolve(args); this.pipe.forEach(processors => { @@ -34,9 +34,17 @@ export default class Pipe<params extends Params = any[]> { } processors.forEach(processor => { - chain = chain.then((args2: any[]) => { - return processor.apply(this, args2); - }); + chain = chain + .then((args2: Params) => { + return processor.apply(this, args2); + }) + .then(args3 => { + if (args.length !== args3.length) { + Log.warn('Bad processor detected', processor); + } + + return args3; + }); }); }); diff --git a/template/chat-window-message.hbs b/template/chat-window-message.hbs index f0db247a..a16d9f2e 100644 --- a/template/chat-window-message.hbs +++ b/template/chat-window-message.hbs @@ -3,6 +3,7 @@ <div class="jsxc-chatmessage__footer"> <div class="jsxc-sender"></div> <div class="jsxc-timestamp"></div> + <div class="jsxc-version"></div> <div class="jsxc-transfer"></div> <div class="jsxc-mark"></div> </div> 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> diff --git a/template/messageHistory.hbs b/template/messageHistory.hbs new file mode 100644 index 00000000..dd465ebe --- /dev/null +++ b/template/messageHistory.hbs @@ -0,0 +1,7 @@ +<h3>{{t "Message_history"}}</h3> + +<ul class="jsxc-message-history"></ul> + +<div class="col-sm-offset-4 col-sm-8"> + <button class="jsxc-button jsxc-button--default jsxc-js-close" type="button">{{t "Close"}}</button> +</div> |