diff options
author | sualko <klaus@jsxc.org> | 2021-04-01 18:21:14 +0300 |
---|---|---|
committer | sualko <klaus@jsxc.org> | 2021-12-29 17:49:50 +0300 |
commit | bb0e27b8892c5c1da003a92545bdb5e106944b3f (patch) | |
tree | 03b04c6bb5a230b40985fe5b83a832375ec417ab | |
parent | 922ef54b738f3b9da4dc1721cf29960b52c57e4f (diff) |
feat: add user avatar (XEP-0084)
-rw-r--r-- | src/bootstrap/plugins.ts | 2 | ||||
-rw-r--r-- | src/plugins/AvatarPEPPlugin.ts | 245 | ||||
-rw-r--r-- | src/ui/AvatarSet.ts | 1 | ||||
-rw-r--r-- | src/ui/dialogs/avatarupload.ts | 2 | ||||
-rw-r--r-- | src/util/FileHelper.ts | 9 | ||||
-rw-r--r-- | src/util/Hash.ts | 14 | ||||
-rw-r--r-- | src/util/Utils.ts | 11 |
7 files changed, 270 insertions, 14 deletions
diff --git a/src/bootstrap/plugins.ts b/src/bootstrap/plugins.ts index dcb8fb4a..933ef013 100644 --- a/src/bootstrap/plugins.ts +++ b/src/bootstrap/plugins.ts @@ -17,6 +17,7 @@ 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'; Client.addPlugin(OTRPlugin); Client.addPlugin(OMEMOPlugin); @@ -36,3 +37,4 @@ Client.addPlugin(CommandPlugin); Client.addPlugin(VersionPlugin); Client.addPlugin(TimePlugin); Client.addPlugin(JingleMessageInitiationPlugin); +Client.addPlugin(AvatarPEPPlugin); diff --git a/src/plugins/AvatarPEPPlugin.ts b/src/plugins/AvatarPEPPlugin.ts new file mode 100644 index 00000000..acea9466 --- /dev/null +++ b/src/plugins/AvatarPEPPlugin.ts @@ -0,0 +1,245 @@ +import { AbstractPlugin, IMetaData } from '../plugin/AbstractPlugin'; +import PluginAPI from '../plugin/PluginAPI'; +import { IContact } from '../Contact.interface'; +import Avatar from '../Avatar'; +import JID from '../JID'; +import Translation from '@util/Translation'; +import Hash from '@util/Hash'; +import { IAvatar } from '@src/Avatar.interface'; +import AvatarUI from '../ui/AvatarSet'; +import FileHelper from '@util/FileHelper'; + +const MIN_VERSION = '4.0.0'; +const MAX_VERSION = '99.0.0'; + +const UANS_BASE = 'urn:xmpp:avatar'; +const UANS_METADATA = +UANS_BASE + ':metadata'; +const UANS_NOTIFY = UANS_METADATA + '+notify'; +const UANS_DATA = UANS_BASE + ':data'; + +export default class AvatarPEPPlugin extends AbstractPlugin { + public static getId(): string { + return 'pep-avatars'; + } + + public static getName(): string { + return 'PEP-based Avatars'; + } + + public static getMetaData(): IMetaData { + return { + description: Translation.t('setting-pep-avatar-enable'), + xeps: [ + { + id: 'XEP-0084', + name: 'User Avatar', + version: '1.1.4', + }, + ], + }; + } + + constructor(pluginAPI: PluginAPI) { + super(MIN_VERSION, MAX_VERSION, pluginAPI); + + let connection = pluginAPI.getConnection(); + + connection.registerHandler(this.onMessageAvatarUpdate, 'http://jabber.org/protocol/pubsub#event', 'message'); + + pluginAPI.addAvatarProcessor(this.avatarProcessor, 49); + pluginAPI.addPublishAvatarProcessor(this.publishAvatarProcessor, 49); + pluginAPI.addFeature(UANS_NOTIFY); + } + + public getStorage() { + return this.pluginAPI.getStorage(); + } + + private publishAvatarProcessor = (avatar: IAvatar | null): Promise<[IAvatar]> => { + let connection = this.pluginAPI.getConnection(); + + if (!avatar || avatar.getData() === undefined) { + let item = $build('metadata', { xmlns: UANS_METADATA }); + + return connection + .getPEPService() + .publish(UANS_METADATA, item.tree(), UANS_METADATA) + .then(function (result) { + if ($(result).attr('type') === 'result') { + return [undefined]; + } else { + return [avatar]; + } + }); + } else { + let data = $build('data', { xmlns: UANS_DATA }).t(avatar.getData()).tree(); + + return connection + .getPEPService() + .publish(UANS_DATA, data, UANS_DATA) + .then((result: any) => { + if ($(result).attr('type') === 'result') { + let metadata; + let hash = Hash.SHA1FromBase64(avatar.getData()); + let i = new Image(); + let iheight = 0; + let iwidth = 0; + let size = FileHelper.getFileSizeFromBase64(avatar.getData()); + + i.onload = function () { + iheight = i.height; + iwidth = i.width; + }; + i.src = 'data:' + avatar.getType() + ';base64,' + avatar.getData(); + metadata = $build('metadata', { xmlns: UANS_METADATA }) + .c('info', { bytes: size, id: hash, height: iheight, width: iwidth, type: avatar.getType() }) + .tree(); + + return connection + .getPEPService() + .publish(UANS_METADATA, metadata, UANS_METADATA) + .then(function (result) { + if ($(result).attr('type') === 'result') { + return [undefined]; + } else { + return [avatar]; + } + }); + } else { + return [avatar]; + } + }); + } + }; + + private onMessageAvatarUpdate = stanza => { + let from = new JID($(stanza).attr('from')); + let metadata = $(stanza).find('metadata[xmlns="urn:xmpp:avatar:metadata"]'); + let data = $(stanza).find('data[xmlns="urn:xmpp:avatar:data"]'); + + if (metadata.length > 0) { + let info = metadata.find('info'); + let contact = this.pluginAPI.getContact(from); + if (!contact) { + this.pluginAPI.Log.warn('No contact found for', from); + return true; + } + + if (info.length > 0) { + let hash = $(info).attr('id'); + + let storedHash = this.getStorage().getItem(from.bare); + if (storedHash === undefined || hash !== storedHash) { + let avatarUI = AvatarUI.get(contact); + this.getStorage().setItem(contact.getJid().bare, hash); + avatarUI.reload(); + } + } else { + let avatarUI = AvatarUI.get(contact); + this.getStorage().setItem(contact.getJid().bare, ''); + avatarUI.reload(); + } + } else if (data.length > 0) { + let contact = this.pluginAPI.getContact(from); + if (!contact) { + this.pluginAPI.Log.warn('No contact found for', from); + return true; + } + + let src = data.text().replace(/[\t\r\n\f]/gi, ''); + const b64str = src.replace(/^.+;base64,/, ''); + let hash = Hash.SHA1FromBase64(b64str); + let avatarUI = AvatarUI.get(contact); + this.getStorage().setItem(contact.getJid().bare, hash); + avatarUI.reload(); + } + + return true; + }; + + private avatarProcessor = async (contact: IContact, avatar: IAvatar): Promise<[IContact, IAvatar]> => { + let storage = this.getStorage(); + let hash = storage.getItem(contact.getJid().bare); + + if (!hash && !avatar) { + try { + const avatarObject = await this.getAvatar(contact); + const data = avatarObject.src.replace(/^.+;base64,/, ''); + avatar = new Avatar(Hash.SHA1FromBase64(data), avatarObject.type, avatarObject.src); + + this.getStorage().setItem(contact.getJid().bare, avatar.getHash() || ''); + + let avatarUI = AvatarUI.get(contact); + avatarUI.reload(); + } catch (err) { + // we could not find any avatar + } + } + + if (!hash || avatar) { + return [contact, avatar]; + } + + try { + avatar = new Avatar(hash); + } catch (err) { + try { + const avatarObject = await this.getAvatar(contact); + + if (avatarObject) { + avatar = new Avatar(hash, avatarObject.type, avatarObject.src); + return [contact, avatar]; + } else { + this.pluginAPI.Log.warn('No local cached avatar found'); + return [contact, avatar]; + } + } catch (err) { + this.pluginAPI.Log.warn('Error during avatar retrieval', err); + return [contact, avatar]; + } + } + + return [contact, avatar]; + }; + + private async getAvatar(contact: IContact): Promise<{ src: string; type: string }> { + let connection = this.pluginAPI.getConnection(); + + return connection + .getPEPService() + .retrieveItems(UANS_METADATA, contact.getJid().bare) + .then(meta => { + let metadata = $(meta).find('metadata[xmlns="urn:xmpp:avatar:metadata"]'); + + if (metadata.length > 0) { + let info = metadata.find('info'); + + if (info && info.length > 0) { + let hash = $(info).attr('id'); + if (hash && hash.length > 0) { + let typeval = $(info).attr('type'); + + let result = connection + .getPEPService() + .retrieveItems(UANS_DATA, contact.getJid().bare) + .then(data => { + if (data && $(data).text() && $(data).text().trim().length > 0) { + let src = $(data) + .text() + .replace(/[\t\r\n\f]/gi, ''); + const b64str = src.replace(/^.+;base64,/, ''); + this.getStorage().setItem(contact.getJid().bare, Hash.SHA1FromBase64(b64str)); + return { src: 'data:' + typeval + ';base64,' + src, type: typeval }; + } else { + throw new Error('No photo available'); + } + }); + return result; + } + } + } + + throw new Error('No photo available'); + }); + } +} diff --git a/src/ui/AvatarSet.ts b/src/ui/AvatarSet.ts index 761e7d7e..880045f6 100644 --- a/src/ui/AvatarSet.ts +++ b/src/ui/AvatarSet.ts @@ -14,7 +14,6 @@ export default class AvatarSet { if (!avatar) { avatar = AvatarSet.avatars[contact.getUid()] = new AvatarSet(contact); } - return avatar; } diff --git a/src/ui/dialogs/avatarupload.ts b/src/ui/dialogs/avatarupload.ts index 5117242e..6aa1cc10 100644 --- a/src/ui/dialogs/avatarupload.ts +++ b/src/ui/dialogs/avatarupload.ts @@ -43,7 +43,7 @@ export default function () { $('.jsxc-avatarimage img').attr('src', thumb); const mimetype = file.type; - const data = thumb.replace(/^.+;base64,/, ''); + const data = thumb.replace(/^.+;base64,/, '').replace(/[\t\r\n\f ]/gi, ''); const hash = Hash.SHA1FromBase64(data); avatar = new Avatar(hash, mimetype, thumb); diff --git a/src/util/FileHelper.ts b/src/util/FileHelper.ts index d4743c88..b238e4aa 100644 --- a/src/util/FileHelper.ts +++ b/src/util/FileHelper.ts @@ -1,3 +1,5 @@ +import Utils from './Utils'; + export default class FileHelper { public static getDataURLFromFile(file: File): Promise<string> { return new Promise((resolve, reject) => { @@ -12,4 +14,11 @@ export default class FileHelper { reader.readAsDataURL(file); }); } + + public static getFileSizeFromBase64(data: string): number { + let base64 = data.replace(/^.+;base64,/, ''); + let buffer = Utils.base64ToArrayBuffer(base64); + + return buffer.byteLength; + } } diff --git a/src/util/Hash.ts b/src/util/Hash.ts index e77aee72..217f0044 100644 --- a/src/util/Hash.ts +++ b/src/util/Hash.ts @@ -1,4 +1,5 @@ import * as sha1 from 'js-sha1'; +import Utils from './Utils'; export default class Hash { public static String(value: string) { @@ -18,19 +19,8 @@ export default class Hash { public static SHA1FromBase64(data: string): string { let base64 = data.replace(/^.+;base64,/, ''); - let buffer = base64ToArrayBuffer(base64); + let buffer = Utils.base64ToArrayBuffer(base64); return sha1(buffer); } } - -function base64ToArrayBuffer(base64String: string) { - let binaryString = window.atob(base64String); - let bytes = new Uint8Array(binaryString.length); - - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - - return bytes.buffer; -} diff --git a/src/util/Utils.ts b/src/util/Utils.ts index f77510c9..d9e06448 100644 --- a/src/util/Utils.ts +++ b/src/util/Utils.ts @@ -51,4 +51,15 @@ export default class Utils { public static prettifyHex(hex: string) { return hex.replace(/(.{8})/g, '$1 ').replace(/ $/, ''); } + + public static base64ToArrayBuffer(base64String: string) { + let binaryString = window.atob(base64String); + let bytes = new Uint8Array(binaryString.length); + + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + return bytes.buffer; + } } |