├── .gitignore ├── assets ├── appdir-icons.png ├── style.css ├── plugin.js ├── util.js ├── modal.js └── stream.js ├── .env-example ├── plugin.html ├── package.json ├── index.html ├── LICENSE ├── modal.html ├── index.js ├── README.md └── stream.html /.gitignore: -------------------------------------------------------------------------------- 1 | certs/ 2 | .vscode/ 3 | node_modules/ 4 | .DS_Store 5 | package-lock.json 6 | .env 7 | yarn.lock 8 | -------------------------------------------------------------------------------- /assets/appdir-icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hootsuite/hootsuite-app-express/HEAD/assets/appdir-icons.png -------------------------------------------------------------------------------- /.env-example: -------------------------------------------------------------------------------- 1 | TWITTER_CONSUMER_KEY="" 2 | TWITTER_CONSUMER_SECRET="" 3 | TWITTER_ACCESS_TOKEN="" 4 | TWITTER_ACCESS_TOKEN_SECRET="" 5 | 6 | SHARED_SECRET="" -------------------------------------------------------------------------------- /plugin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hootsuite Sample App 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | hey earth 15 | 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "best-sample", 3 | "version": "1.0.0", 4 | "description": "a sample app backend in express", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node index.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git@github.hootops.com:Platform/sdk-sample-app.git" 13 | }, 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "body-parser": "1.19.0", 18 | "express": "4.17.1", 19 | "js-sha512": "0.4.0", 20 | "socket.io": "2.3.0", 21 | "twit": "2.2.11" 22 | }, 23 | "engines": { 24 | "node": "12.4.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /assets/style.css: -------------------------------------------------------------------------------- 1 | .hs_jsonDump { 2 | flex: 1 1 auto; 3 | overflow: scroll; 4 | } 5 | 6 | .hs_btnCtaSml:focus { 7 | outline: none; 8 | } 9 | 10 | .hs_message { 11 | flex: 0 1 auto; 12 | } 13 | 14 | .hs_showJsonButton { 15 | flex: 0 1 auto; 16 | } 17 | 18 | .hs_messages { 19 | margin-top: 40px; 20 | overflow: scroll; 21 | } 22 | 23 | .hs_sdkContainer { 24 | display: flex; 25 | margin-bottom: 10px; 26 | } 27 | 28 | .hs_sdkContainer:last-child { 29 | margin-bottom: 0px; 30 | } 31 | 32 | .hs_sdkInput { 33 | flex-grow: 1; 34 | } 35 | 36 | .hs_sdkFunctions { 37 | position: fixed; 38 | bottom: 0; 39 | padding: 10px; 40 | width: 100%; 41 | box-sizing: border-box; 42 | } 43 | 44 | .hs_topBar { 45 | display: block; 46 | } 47 | 48 | .hs_getTwitterAccountsOutput { 49 | margin-left: 10px; 50 | } 51 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Hootsuite Sample App 7 | 8 | 9 | 10 | 11 |

Welcome to the SDK Sample App

12 |

13 | This page is not used by the actual app itself. It only provides some links to 14 | navigate to the pages that the app uses. 15 |

16 |

17 | 18 | Click here 19 | 20 | to view instructions on how to configure your Hootsuite App 21 |

22 |

Access:

23 | 28 | 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Hootsuite 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /assets/plugin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function sendToAppHandler(data) { 3 | if (data.post.network === 'TWITTER') { 4 | var httpRequest = new XMLHttpRequest(); 5 | httpRequest.open( 6 | 'GET', 7 | window.location.origin + '/tweets/' + data.post.id 8 | ); 9 | httpRequest.setRequestHeader('secretKey', 'super_secret') 10 | httpRequest.send(); 11 | httpRequest.onreadystatechange = function() { 12 | var response = httpRequest.responseText; 13 | if (httpRequest.status === 200) { 14 | var hydratedData = convertTwitterPayload(response); 15 | window.localStorage.jsonData = JSON.stringify(hydratedData); 16 | } else { 17 | window.localStorage.jsonData = JSON.stringify({error:httpRequest.status}); 18 | } 19 | } 20 | } else { 21 | window.localStorage.jsonData = JSON.stringify(data); 22 | } 23 | // open a custom modal, where the data will be read and displayed on the dashboard 24 | hsp.showCustomPopup(window.location.origin + '/modal', 'Post Info'); 25 | } 26 | 27 | // Only necessary if you want consistency between social network responses. 28 | // Hydrate with more data as required (i.e. `post.conversation`, `post.attachments`). 29 | function convertTwitterPayload(response) { 30 | var data = {}; 31 | var payload = JSON.parse(response); 32 | // move profile obj 33 | data.profile = {}; 34 | data.profile = payload.user; 35 | 36 | // manually construct post obj 37 | data.post = {}; 38 | data.post.network = "TWITTER"; 39 | data.post.id = payload.id; 40 | data.post.datetime = payload.created_at; 41 | data.post.source = payload.source; 42 | // counts 43 | data.post.counts = {}; 44 | data.post.counts.likes = payload.favorite_count; 45 | data.post.counts.shares = payload.retweet_count; 46 | data.post.counts.replies = payload.entities.user_mentions.length || 0; 47 | // content 48 | data.post.content = {}; 49 | data.post.content.body = payload.text; 50 | // user 51 | data.post.user = {}; 52 | data.post.user.userid = payload.user.id; 53 | data.post.user.username = payload.user.name; 54 | 55 | return data; 56 | } 57 | 58 | document.addEventListener('DOMContentLoaded', function () { 59 | // the hsp.init function initializes the Hootsuite App SDK 60 | // it should only be used once per component because it 61 | // resets event handlers and any other configuration 62 | hsp.init({ 63 | useTheme: true 64 | }); 65 | 66 | // binds the "Send to " button in all Hootsuite streams 67 | // to a function of your choice. Can be anonymous as well 68 | hsp.bind('sendtoapp', sendToAppHandler); 69 | }); 70 | -------------------------------------------------------------------------------- /assets/util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function getSingleElementByClassName(className) { 3 | return document.getElementsByClassName(className)[0]; 4 | } 5 | 6 | function replaceInnerInClass(className, text) { 7 | var theClass = getSingleElementByClassName(className); 8 | theClass.innerHTML = ''; 9 | theClass.appendChild(document.createTextNode(text)); 10 | } 11 | 12 | function appendTextToClass(className, text) { 13 | getSingleElementByClassName(className).appendChild(document.createTextNode(text)); 14 | } 15 | 16 | function replaceTextInClass(className, text) { 17 | replaceInnerInClass(className, ''); 18 | appendTextToClass(className, text); 19 | } 20 | 21 | function getQueryParam(paramName) { 22 | var url = window.location.href; 23 | var queryString = url.split('?'); 24 | queryString = queryString[queryString.length-1]; 25 | var params = queryString.split('&'); 26 | for (var i = 0; i < params.length; i++) { 27 | var splitPair = params[i].split('='); 28 | if (splitPair[0] === paramName) { 29 | return splitPair[1]; 30 | } 31 | } 32 | }; 33 | 34 | function onSignIn (googleUser) { 35 | var profile = googleUser.getBasicProfile(); 36 | replaceTextInClass('hs_loggedIn', 'Logged in as ' + profile.ig + ' (Google)'); 37 | getSingleElementByClassName('hs_logout').style.display = 'block'; 38 | } 39 | 40 | function signOut () { 41 | var auth2 = gapi.auth2.getAuthInstance(); 42 | auth2.signOut().then(function () { 43 | replaceTextInClass('hs_loggedIn', 'Not logged in'); 44 | }); 45 | getSingleElementByClassName('hs_logout').style.display = 'none'; 46 | } 47 | 48 | function prettifyTimeAgo(datetime) { 49 | var timeDiff = Date.now().valueOf() - datetime.valueOf(); 50 | 51 | // milliseconds 52 | if (timeDiff < 1000) { 53 | return ' just now'; 54 | } 55 | 56 | // seconds 57 | timeDiff = Math.round(timeDiff / 1000); 58 | if (timeDiff < 30) { 59 | return timeDiff + ' secs ago'; 60 | } 61 | 62 | // minutes 63 | timeDiff = Math.round(timeDiff / 60); 64 | if (timeDiff < 50) { 65 | return timeDiff + ' mins ago'; 66 | } 67 | 68 | // hours 69 | timeDiff = Math.round(timeDiff / 60); 70 | if (timeDiff < 24) { 71 | return timeDiff + ' hours ago'; 72 | } 73 | 74 | // days 75 | timeDiff = Math.round(timeDiff / 24); 76 | if (timeDiff < 7) { 77 | return timeDiff + ' days ago'; 78 | } 79 | 80 | // weeks 81 | timeDiff = Math.round(timeDiff / 7); 82 | if (timeDiff < 4) { 83 | return timeDiff + ' weeks ago'; 84 | } 85 | 86 | // months 87 | timeDiff = Math.round(timeDiff / 4); 88 | return timeDiff + ' months ago'; 89 | } 90 | 91 | function googleAuthInit() { 92 | // loads auth2 in order to show logout button if user is signed in 93 | gapi.load('auth2', { 94 | callback: function () { 95 | gapi.auth2.init().then(function() { 96 | if (gapi.auth2.getAuthInstance().isSignedIn.get()) { 97 | getSingleElementByClassName('hs_logout').style.display = 'block'; 98 | } 99 | }, function(obj) { 100 | if (obj.error === "idpiframe_initialization_failed") { 101 | console.log('You must replace YOUR-CLIENT-ID-HERE with your client id in stream.html and modal.html'); 102 | } 103 | }); 104 | } 105 | }); 106 | } 107 | 108 | function getGeolocation() { 109 | function success(pos) { 110 | let coords = pos.coords; 111 | let lngLat = coords.longitude + ' ' + coords.latitude; 112 | replaceTextInClass('hs_showGeolocation', lngLat); 113 | } 114 | function error(err) { 115 | replaceTextInClass('hs_showGeolocation', 'No location'); 116 | alert('Could not get Geolocation. Check your browser location privacy settings.', err); 117 | } 118 | replaceTextInClass('hs_showGeolocation', 'Loading...'); 119 | navigator.geolocation.getCurrentPosition(success, error); 120 | } 121 | -------------------------------------------------------------------------------- /modal.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hootsuite Sample App 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 | 24 |
    25 |
  • 26 | 27 |
  • 28 |
29 | 30 | 31 |

Hootsuite Component Sample

32 |
33 | 34 | 35 | 44 |
45 |
46 |
47 | Avatar 48 |
49 | 50 |
51 | 52 | 53 | 54 |
55 |

56 | 57 |

58 |
    59 |
  • 60 | 61 | 62 |
  • 63 |
  • 64 | 65 | 66 |
  • 67 |
  • 68 | 69 | 70 |
  • 71 |
72 |
73 |
74 | 77 | 80 | 83 | 86 |
87 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const express = require('express'); 3 | const https = require('https'); 4 | const http = require('http'); 5 | const fs = require('fs'); 6 | const bodyParser = require('body-parser'); 7 | const sha512 = require('js-sha512'); 8 | var Twitter = require('twit'); 9 | 10 | const app = express(); 11 | 12 | // create application/json parser 13 | const jsonParser = bodyParser.json(); 14 | // create application/x-www-form-urlencoded parser 15 | const urlencodedParser = bodyParser.urlencoded({ extended: true }); 16 | 17 | app.use(jsonParser); 18 | app.use(urlencodedParser); 19 | 20 | var secret = ''; 21 | 22 | //--- Client for hydrating twitter data - ADD YOUR OWN CREDENTIALS --- 23 | //--- Otherwise, app will not show up in plugin list --- 24 | try { 25 | var twitterClient = new Twitter({ 26 | consumer_key: process.env.TWITTER_CONSUMER_KEY, 27 | consumer_secret: process.env.TWITTER_CONSUMER_SECRET, 28 | access_token: process.env.TWITTER_ACCESS_TOKEN, 29 | access_token_secret: process.env.TWITTER_ACCESS_TOKEN_SECRET 30 | }); 31 | } catch(error) { 32 | console.log('No Twitter API credenitals found'); 33 | } 34 | 35 | function sleep(ms) { 36 | return new Promise(resolve => setTimeout(resolve, ms)); 37 | } 38 | 39 | async function sendFakeMessages(socket) { 40 | // emit a scripted set of messages 41 | await sleep(2000); 42 | socket.emit('stream update', 'hello!'); 43 | await sleep(2000); 44 | socket.emit('stream update', 'hey, how are you?'); 45 | await sleep(2000); 46 | socket.emit('stream update', 'i\'m good thanks, how are you?'); 47 | await sleep(2000); 48 | socket.emit('stream update', 'i\'m good too'); 49 | } 50 | 51 | // See "Configuring your Shared Secret" section in README.md 52 | try { 53 | secret = process.env.SHARED_SECRET; 54 | } catch (err) { 55 | console.log('SHARED_SECRET is missing from .env'); 56 | } 57 | 58 | app.get('/gen-token', (req, res) => { 59 | // Combines information to create an auth token 60 | // Auth token is retrieved by an AJAX request from stream.js 61 | if (secret === '') { 62 | console.log('Token generation failed because of missing shared secret'); 63 | res.status(500).send({error: 'Token generation failed because of missing shared secret'}); 64 | } else { 65 | res.send(sha512(req.query.userId.toString() + 66 | req.query.timestamp.toString() + 67 | req.query.url.toString() + secret)); 68 | } 69 | }); 70 | 71 | app.post('/stream', (req, res) => { 72 | res.sendFile(__dirname + '/stream.html'); 73 | }); 74 | 75 | app.get('/stream', (req, res) => { 76 | res.sendFile(__dirname + '/stream.html'); 77 | }); 78 | 79 | app.use('/assets', express.static('assets')); 80 | 81 | app.post('/plugin', (req, res) => { 82 | res.sendFile(__dirname + '/plugin.html'); 83 | }); 84 | 85 | app.get('/plugin', (req, res) => { 86 | res.sendFile(__dirname + '/plugin.html'); 87 | }); 88 | 89 | app.get('/modal', (req, res) => { 90 | res.sendFile(__dirname + '/modal.html'); 91 | }); 92 | 93 | app.post('/webhooks', (req, res) => { 94 | console.log("Webhook content:\n\n%s", JSON.stringify(req.body)); 95 | res.status(200).end(); 96 | }); 97 | 98 | //used for webhooks with urlencoded payloads 99 | app.post('/callbacks', (req, res) => { 100 | console.log("Callback content"); 101 | console.log(req.body); 102 | 103 | res.status(200).send('{"success":true}').end(); 104 | }); 105 | 106 | // GET /twitterAccounts?account_ids= 107 | app.get('/twitterAccounts', (req, res) => { 108 | // there is, of course, better ways to do this 109 | if (req.header('secretKey') == 'super_secret') { 110 | var accountIds = req.query.accountIds; 111 | if (!accountIds) { 112 | res.status(400).send('Missing Twitter Credentials'); 113 | return; 114 | } else { 115 | twitterClient.get('users/lookup', { user_id: accountIds }) 116 | .catch(function(err) { 117 | console.log('caught error', err.stack); 118 | }) 119 | .then(function (result) { 120 | var accountNames = result.data.map(function(account) { 121 | return account.name 122 | }); 123 | res.send(accountNames); 124 | return; 125 | }); 126 | } 127 | } else { 128 | res.sendStatus(401); 129 | } 130 | }); 131 | 132 | // GET /tweets/ 133 | app.get('/tweets/:tweetId', (req, res) => { 134 | if (req.header('secretKey') == 'super_secret') { 135 | if (!req.params.tweetId) { 136 | res.sendStatus(400); 137 | return; 138 | } else { 139 | twitterClient.get('statuses/show', { id: req.params.tweetId }) 140 | .catch(function(err) { 141 | console.log('caught error', err.stack); 142 | }) 143 | .then(function (result) { 144 | res.send(result.data); 145 | return; 146 | }); 147 | } 148 | } else { 149 | res.sendStatus(401); 150 | } 151 | }); 152 | 153 | app.get('/', (req, res) => { 154 | res.sendFile(__dirname + '/index.html'); 155 | }); 156 | 157 | // All Hoosuite apps require HTTPS, so in order to host locally 158 | // you must have some certs. They don't need to be issued by a CA for development, 159 | // but for production they definitely do! Heroku adds its own TLS, 160 | // so you don't have to worry about it as long as TLS is enabled on your Heroku app. 161 | if (fs.existsSync('certs/server.crt') && fs.existsSync('certs/server.key')) { 162 | const certificate = fs.readFileSync('certs/server.crt').toString(); 163 | const privateKey = fs.readFileSync('certs/server.key').toString(); 164 | const options = {key: privateKey, cert: certificate}; 165 | 166 | var server = https.createServer(options, app).listen(process.env.PORT || 5000); 167 | console.log(`Example app listening on port ${process.env.PORT || 5000} using HTTPS`); 168 | } else { 169 | var server = http.createServer(app).listen(process.env.PORT || 5000); 170 | console.log(`Example app listening on port ${process.env.PORT || 5000}`); 171 | } 172 | 173 | const io = require('socket.io')(server); 174 | 175 | io.on('connection', function (socket) { 176 | sendFakeMessages(socket); 177 | socket.on('restart', function(data) { 178 | sendFakeMessages(socket); 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /assets/modal.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file only interfaces with the data sent by plugin.js 3 | * There isn't an awful lot of Hootsuite-specific code in here, 4 | * this is mostly just to show an example of the things you can 5 | * do with the Hootsuite SDK. 6 | */ 7 | 'use strict'; 8 | 9 | function populateMessage(data) { 10 | // dumps pretty json into hidden area 11 | var prettyJSONData = JSON.stringify(data, null, 4); 12 | appendTextToClass('hs_jsonDump', prettyJSONData); 13 | 14 | // finds network-specific information 15 | var avatarURL = 'https://hootsuite.com/dist/images/logos/hootsuite/owly.png'; 16 | var displayName = 'Hootsuite'; 17 | var profileURL = 'https://hootsuite.com/'; 18 | switch (data.post.network) { 19 | case 'FACEBOOK': 20 | avatarURL = data.profile.picture; 21 | displayName = data.profile.name; 22 | profileURL = data.profile.link; 23 | break; 24 | case 'TWITTER': 25 | avatarURL = data.profile.profile_image_url_https; 26 | displayName = data.profile.name; 27 | profileURL = data.profile.url; 28 | break; 29 | case 'INSTAGRAM': 30 | avatarURL = data.profile.profile_picture; 31 | displayName = data.profile.full_name; 32 | profileURL = 'https://instagram.com/' + data.post.user.username; 33 | break; 34 | case 'YOUTUBE': 35 | avatarURL = data.profile.avatar_url; 36 | displayName = data.profile.name; 37 | profileURL = 'https://www.youtube.com/channel/' + data.profile.userid; 38 | break; 39 | } 40 | 41 | // displays user's display name and attaches url to username 42 | appendTextToClass('hs_userName', displayName); 43 | getSingleElementByClassName('hs_userName').setAttribute('href', profileURL); 44 | getSingleElementByClassName('hs_avatar').setAttribute('title', data.post.user.username); 45 | 46 | // displays user screen name as hover tooltip 47 | var screenName = data.post.user.username; 48 | if (screenName === null) { 49 | getSingleElementByClassName('hs_userName').removeAttribute('title'); 50 | } 51 | getSingleElementByClassName('hs_userName').setAttribute('title', '@' + screenName); 52 | 53 | // displays timestamp, links to post, shows where something was posted from 54 | var timestamp = new Date(data.post.datetime); 55 | var timeAgo = prettifyTimeAgo(timestamp); 56 | appendTextToClass('hs_postTime', timeAgo); 57 | var timeElement = getSingleElementByClassName('hs_postTime'); 58 | timeElement.setAttribute('href', data.post.href); 59 | if (data.post.source !== '') { 60 | timeElement.setAttribute('title', timestamp.toLocaleString() + " via " + data.post.source); 61 | } else { 62 | timeElement.setAttribute('title', timestamp.toLocaleString()); 63 | } 64 | 65 | 66 | // display likes, comments, shares 67 | // also possible to link to network-specific likes, comments, and share lists 68 | appendTextToClass('hs_likesCount', data.post.counts.likes); 69 | appendTextToClass('hs_commentsCount', data.post.counts.replies); 70 | appendTextToClass('hs_sharesCount', data.post.counts.shares); 71 | 72 | // displays post body 73 | appendTextToClass('hs_postBody', data.post.content.body); 74 | 75 | // displays avatar 76 | getSingleElementByClassName('hs_avatarImage').setAttribute('src', avatarURL); 77 | 78 | getSingleElementByClassName('hs_avatar').addEventListener('click', function () { 79 | // This is a Hootsuite SDK function which displays another modal. 80 | // This modal can be populated with any data. 81 | hsp.customUserInfo({ 82 | fullName: data.post.user.username, 83 | avatar: avatarURL, 84 | profileURL: data.profile.link, 85 | userLocation: data.profile.location, 86 | bio: data.profile.bio, 87 | extra: [ 88 | { 89 | label: "Gender", 90 | value: data.profile.gender 91 | } 92 | ] 93 | }); 94 | }); 95 | } 96 | 97 | function startEventListeners() { 98 | getSingleElementByClassName('hs_showJsonButton').addEventListener('click', function () { 99 | if (getSingleElementByClassName('hs_jsonDump').style.display === 'none') { 100 | replaceInnerInClass('hs_showJsonButton', 'Hide JSON Payload'); 101 | getSingleElementByClassName('hs_jsonDump').style.display = 'block'; 102 | getSingleElementByClassName('hs_copyJsonButton').style.display = 'inline'; 103 | } else { 104 | replaceInnerInClass('hs_showJsonButton', 'Show JSON Payload'); 105 | getSingleElementByClassName('hs_jsonDump').style.display = 'none'; 106 | getSingleElementByClassName('hs_copyJsonButton').style.display = 'none'; 107 | } 108 | }); 109 | 110 | getSingleElementByClassName('hs_copyJsonButton').addEventListener('click', function () { 111 | var range = document.createRange(); 112 | range.selectNode(getSingleElementByClassName('hs_jsonDump')); 113 | window.getSelection().empty(); 114 | window.getSelection().addRange(range); 115 | document.execCommand('copy'); 116 | window.getSelection().empty(); 117 | replaceTextInClass('hs_copyJsonButton', 'Copied!'); 118 | }); 119 | 120 | getSingleElementByClassName('hs_showGeolocation').addEventListener('click', function () { 121 | getGeolocation(); 122 | }); 123 | 124 | // sets up dropdown menu 125 | var topBarControl = getSingleElementByClassName('hs_topBarControlsBtn'); 126 | topBarControl.addEventListener('click', function (event) { 127 | event.preventDefault(); 128 | if (getSingleElementByClassName('hs_dropdownMenuList').style.display === 'none') { 129 | topBarControl.classList.add('active'); 130 | getSingleElementByClassName('hs_dropdownMenuList').style.display = 'block'; 131 | } else { 132 | topBarControl.classList.remove('active'); 133 | getSingleElementByClassName('hs_dropdownMenuList').style.display = 'none'; 134 | } 135 | }); 136 | 137 | getSingleElementByClassName('hs_logout').addEventListener('click', function () { 138 | window.localStorage.loggedIn = 'false'; 139 | }); 140 | 141 | getSingleElementByClassName('hs_logout').addEventListener('click', signOut); 142 | } 143 | 144 | // for our purposes this is the same thing as jQuery's $(document).ready(...) 145 | document.addEventListener('DOMContentLoaded', function () { 146 | var data = JSON.parse(window.localStorage.jsonData); 147 | //currently handling displaying http errors this way but it can be improved on 148 | if ("error" in data) { 149 | getSingleElementByClassName('error').style.display = 'block'; 150 | } 151 | populateMessage(data); 152 | startEventListeners(); 153 | googleAuthInit(); 154 | }); 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hootsuite-app-express 2 | 3 | > SDK Sample app for external developers to install 4 | 5 | ## Table of Contents 6 | 7 | - [Overview](#overview) 8 | - [What is the Hootsuite dashboard?](#what-is-the-hootsuite-dashboard) 9 | - [What does a Hootsuite app do?](#what-does-a-hootsuite-app-do) 10 | - [What does this Hootsuite app do?](#what-does-this-hootsuite-app-do) 11 | - [Getting started](#getting-started) 12 | - [Using Heroku](#using-heroku) 13 | - [Using another solution](#using-another-solution) 14 | - [Configuration](#configuration) 15 | - [Configuring your Hootsuite App](#configuring-your-hootsuite-app) 16 | - [Configuring your shared secret](#configuring-your-shared-secret-for-use-with-attachfiletomessage) 17 | - [Configuring your Google client ID](#configuring-your-google-client-id) 18 | - [Other things you can do with the Plugin SDK](#other-things-you-can-do-with-the-Plugin-SDK) 19 | - [Useful Links](#useful-links) 20 | 21 | ## Overview 22 | 23 | ### What is the Hootsuite dashboard? 24 | 25 | The Hootsuite dashboard is a tool which allows you to manage all of your social media in one place. 26 | 27 | ### What does a Hootsuite app do? 28 | 29 | A Hootsuite app uses the [Hootsuite JavaScript SDK](https://app-directory.s3.amazonaws.com/docs/sdk/v4.0/index.html) in order to extend the Hootsuite dashboard by adding custom integrations with other service and useful features for users. Hootsuite apps can either be free or monetized. 30 | 31 | ### What does this Hootsuite app do? 32 | 33 | This Sample App currently only contains a sample plugin component (allows users to send things from Hootsuite streams to an app) and a sample stream component. The plugin listens for a `'sendtoapp'` event and opens a modal, populated with the data from the message it was activated on. The stream displays some sample messages and has buttons that activate SDK functions. Components in a Hootsuite app are largely seperate but it is possible to communicate between them. 34 | 35 | The Sample App can be hosted locally, or on Heroku or some other hosting service. The backend is a very simple NodeJS/Express app but you can swap out the backend with anything as long as you meet a few requirements. These are listed below in the [Using another hosting solution](#using-another-hosting-solution) section. 36 | 37 | ## Getting started 38 | 39 | ### Using Heroku 40 | 41 | 1. [Setup Heroku](https://devcenter.heroku.com/articles/getting-started-with-nodejs#set-up). 42 | 2. Clone this repository. `git clone https://github.com/HootsuiteApps/sdk-sample.git` 43 | 3. [Create a Heroku app](https://devcenter.heroku.com/articles/getting-started-with-nodejs#deploy-the-app) for this sample app. 44 | 4. Configure Twitter (Optional): 45 | - `heroku plugins:install heroku-config` to install [heroku-config](https://github.com/xavdid/heroku-config) plugin 46 | - rename .env-example to .env 47 | - open .env to fill in the fields with your Twitter keys and access tokens. 48 | - `heroku config:push` to write the contents of .env to heroku config 49 | 5. `git push heroku master` to push this app to Heroku. Heroku should detect that this app is a Node/Express app and run your index.js file 50 | 6. Once Heroku says that it's done use `heroku open` and add /modal to the URL it opened in your browser. If it comes up with a blank page that has a "Show JSON Payload" button then the web server is setup correctly. 51 | 52 | ## Configuration 53 | 54 | ### Configuring your Hootsuite App 55 | 56 | 1. If you already have a Hootsuite developer account head over to [your Hootsuite app directory management page](https://hootsuite.com/developers/my-apps) and create an app, and inside that app create a plugin component. 57 | 2. Edit the plugin component and enter the following into the fields: For the plugin component Service URL use your endpoint for plugin.html, if you used Heroku and Node this would be `https://.herokuapp.com/plugin` . Also, check off the Default Install box. 58 | 3. If you'd like to install a stream example as well you should create another component, but this time make it a stream. Edit it again and this time enter `https://.herokuapp.com/stream` as your Service URL. 59 | 4. Install your app by going to your [Hootsuite dashboard](https://hootsuite.com/dashboard) and navigating to the app directory (puzzle piece at the bottom of the left sidebar). Your app should be under Developer, install it. 60 | 5. Test it by going to your [Hootsuite dashboard](https://hootsuite.com/dashboard), clicking the elipsis on any post and hitting Send to . This should pop up a modal with some info about the post you sent to the app. 61 | 62 | ### Configuring your shared secret (for use with attachFileToMessage()) 63 | 64 | 1. Edit your app in [My Apps](https://hootsuite.com/developers/my-apps) 65 | 2. Under "Authentication Type" select "Single Sign-On (SHA-512)" 66 | 3. Create a shared secret (preferably by randomly generating it) and enter it into the "Shared Secret" field and hit save at the bottom of the page. 67 | 4. Paste the Shared Secret into .env, run `heroku config:push` and you should be good to go! 68 | 69 | ### Configuring your Google client ID 70 | 71 | 1. Access the [Google Cloud Console](https://console.cloud.google.com/) and create a project. 72 | 2. Go to `APIs and Service -> Credentials` and hit create credentials and create a client ID for a web app. Setup your Authorized Javascript origins with your Heroku domain. 73 | 3. Copy your OAuth 2 Client ID and paste it into `modal.html` and `stream.html` where it says `YOUR-CLIENT-ID-HERE`. 74 | 4. Google Sign-in should now work seamlessly. If it doesn't work then ensure that your Authorized Javascript origins are set up correctly in the [Google Cloud Console](https://console.cloud.google.com/). 75 | 76 | ## Using another hosting solution 77 | 78 | You can host your app on any service, but here are few things to keep in mind: 79 | 80 | * You need to accept POST requests on your plugin and stream endpoints. 81 | * You need to host the static CSS, Javascript, and icons somehow. 82 | * Your endpoints need to have HTTPS. 83 | 84 | ## Other things you can do with the Plugin SDK 85 | 86 | * Use it to send a post to your own servers and manipulate/save it. 87 | * Process the images from a post and use the [attachFileToMessage](https://app-directory.s3.amazonaws.com/docs/sdk/v4.0/global.html#attachMedia) SDK function to put the processed images into the compose box. 88 | * Compose messages using the [composeMessage](https://app-directory.s3.amazonaws.com/docs/sdk/v4.0/global.html#composeMessage) SDK function based on the contents of a message that was sent to your plugin. 89 | 90 | ## Useful Links 91 | 92 | * [App Directory SDK Reference](https://app-directory.s3.amazonaws.com/docs/sdk/v4.0/index.html) 93 | * [Hootsuite Developer Docs](https://developer.hootsuite.com/docs) 94 | -------------------------------------------------------------------------------- /stream.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hootsuite Sample Stream 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |
22 | 23 |
    24 |
  • 25 | SDK Functions 26 |
  • 27 |
  • 28 | 29 |
  • 30 |
31 | 32 | 33 |

Hootsuite Component Sample

34 |
35 | 36 | 37 | 46 | 137 |
138 |
139 |
140 |
141 |
142 | Avatar 143 |
144 | 145 |
146 | Username 147 | 1 hour ago 148 | 149 |
150 |

151 | Hello World! 152 |

153 |
154 |
    155 |
  • 156 | 1 157 | 158 |
  • 159 |
  • 160 | 2 161 | 162 |
  • 163 |
  • 164 | 3 165 | 166 |
  • 167 |
168 |
169 |
170 |
171 | 172 | 173 | -------------------------------------------------------------------------------- /assets/stream.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function bindApiButtons() { 4 | getSingleElementByClassName('hs_showImagePreview').addEventListener('click', function () { 5 | hsp.showImagePreview(getSingleElementByClassName('hs_showImagePreviewInput').value, 'https://hootsuite.com'); 6 | }); 7 | 8 | getSingleElementByClassName('hs_showLightbox').addEventListener('click', function () { 9 | // similar to showImagePreview 10 | hsp.showLightbox(getSingleElementByClassName('hs_showLightboxInput').value); 11 | }); 12 | 13 | getSingleElementByClassName('hs_showStatusMessage').addEventListener('click', function () { 14 | // type can be info, error, warning or success 15 | hsp.showStatusMessage(getSingleElementByClassName('hs_showStatusMessageInput').value, getSingleElementByClassName('hs_showStatusMessageTypeInput').value); 16 | }); 17 | 18 | getSingleElementByClassName('hs_showUser').addEventListener('click', function () { 19 | // opens a modal with info about a twitter user 20 | hsp.showUser(getSingleElementByClassName('hs_showUserInput').value); 21 | }); 22 | 23 | getSingleElementByClassName('hs_composeMessage').addEventListener('click', function () { 24 | hsp.composeMessage(getSingleElementByClassName('hs_composeMessageInput').value); 25 | }); 26 | 27 | getSingleElementByClassName('hs_saveData').addEventListener('click', function () { 28 | hsp.saveData(getSingleElementByClassName('hs_saveDataInput').value, function(){}); 29 | }); 30 | 31 | getSingleElementByClassName('hs_getData').addEventListener('click', function () { 32 | hsp.getData(function (data){ 33 | replaceTextInClass('hs_getDataOutput', data); 34 | }); 35 | }); 36 | 37 | getSingleElementByClassName('hs_assignItem').addEventListener('click', function () { 38 | // Generates a 16 character random string to use as the messageId 39 | // because the messageId must be unique or Hootsuite will return a 500 error. 40 | // This can be saved if you'd like to do something using the `sendassignmentupdates` event 41 | // or with the Assignment Event Request API Callback 42 | var randomString = ''; 43 | var possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 44 | for (var i = 0; i < 16; i++) { 45 | randomString += possible.charAt(Math.floor(Math.random() * possible.length)); 46 | } 47 | 48 | hsp.assignItem({ 49 | messageId: randomString, 50 | messageAuthor: getSingleElementByClassName('hs_assignMessageAuthor').value, 51 | messageAuthorAvatar: getSingleElementByClassName('hs_assignMessageAuthorAvatar').value, 52 | message: getSingleElementByClassName('hs_assignMessage').value 53 | }); 54 | }); 55 | 56 | getSingleElementByClassName('hs_attachFile').addEventListener('click', function () { 57 | hsp.getMemberInfo(function (data) { 58 | var timestamp = Math.floor(Date.now() / 1000); 59 | var url = getSingleElementByClassName('hs_attachFileInput').value; 60 | var extension = url.split('.'); 61 | if (extension.length !== 0) { 62 | extension = extension[extension.length - 1]; 63 | } else { 64 | extension = 'jpg'; 65 | } 66 | var name = getSingleElementByClassName('hs_attachFileImageName').value; 67 | var httpRequest = new XMLHttpRequest(); 68 | var once = false; 69 | // generates a token on the backend because secret must be kept on backend 70 | httpRequest.open( 'GET', 71 | window.location.origin + 72 | '/gen-token?userId=' + encodeURIComponent(data.userId) + 73 | '×tamp=' + encodeURIComponent(timestamp) + 74 | '&url=' + encodeURIComponent(url)); 75 | httpRequest.send(); 76 | httpRequest.onreadystatechange = function () { 77 | if (httpRequest.responseText !== '' && !once) { 78 | hsp.attachFileToMessage({ 79 | url: url, 80 | name: name, 81 | extension: extension, 82 | timestamp: timestamp, 83 | token: httpRequest.responseText 84 | }); 85 | once = true; 86 | } 87 | }; 88 | }); 89 | }); 90 | 91 | getSingleElementByClassName('hs_updatePlacementSubtitle').addEventListener('click', function () { 92 | hsp.updatePlacementSubtitle(getSingleElementByClassName('hs_updatePlacementSubtitleInput').value); 93 | }); 94 | 95 | getSingleElementByClassName('hs_getTwitterAccounts').addEventListener('click', function () { 96 | hsp.getTwitterAccounts(function (data) { 97 | var httpRequest = new XMLHttpRequest(); 98 | httpRequest.open( 99 | 'GET', 100 | window.location.origin + '/twitterAccounts?accountIds=' + data.join() 101 | ); 102 | httpRequest.setRequestHeader('secretKey', 'super_secret') 103 | httpRequest.send(); 104 | httpRequest.onreadystatechange = function() { 105 | replaceTextInClass('hs_getTwitterAccountsOutput', httpRequest.responseText); 106 | } 107 | }); 108 | }); 109 | 110 | getSingleElementByClassName('hs_retweet').addEventListener('click', function () { 111 | hsp.getTwitterAccounts(function (data) { 112 | var splitURL = getSingleElementByClassName('hs_retweetInput').value.split('/'); 113 | var id = splitURL[splitURL.length - 1]; 114 | hsp.retweet(id, data[0]); 115 | }); 116 | }); 117 | 118 | getSingleElementByClassName('hs_showFollowDialog').addEventListener('click', function () { 119 | hsp.showFollowDialog(getSingleElementByClassName('hs_showFollowDialogName').value, true); 120 | }); 121 | 122 | getSingleElementByClassName('hs_logout').addEventListener('click', signOut); 123 | 124 | getSingleElementByClassName('hs_showGeolocation').addEventListener('click', function () { 125 | getGeolocation(); 126 | }); 127 | } 128 | 129 | function loadTopBars() { 130 | var topBarControls = document.getElementsByClassName('hs_topBarControlsBtn'); 131 | 132 | Array.prototype.forEach.call(topBarControls, function(topBarControl) { 133 | topBarControl.addEventListener('click', function (event) { 134 | var topBarDropdowns = document.getElementsByClassName('hs_topBarDropdown'); 135 | for (var i = 0; i < topBarDropdowns.length; i++) { 136 | if (event.currentTarget.getAttribute('data-dropdown') === topBarDropdowns[i].getAttribute('data-dropdown')) { 137 | if (topBarDropdowns[i].style.display === 'none') { 138 | topBarDropdowns[i].style.display = 'block'; 139 | event.currentTarget.classList.add('active'); 140 | } else { 141 | topBarDropdowns[i].style.display = 'none'; 142 | event.currentTarget.classList.remove('active'); 143 | } 144 | } else { 145 | // remove active on all dropdown buttons except the one that was clicked 146 | var topBarBtns = document.getElementsByClassName('hs_topBarControlsBtn'); 147 | for (var p = 0; p < topBarBtns.length; p++) { 148 | if (topBarBtns[p].getAttribute('data-dropdown') !== event.currentTarget.getAttribute('data-dropdown')) { 149 | topBarBtns[p].classList.remove('active'); 150 | } 151 | } 152 | // close all dropdowns except the one that was clicked 153 | topBarDropdowns[i].style.display = 'none'; 154 | } 155 | } 156 | }); 157 | }); 158 | } 159 | 160 | // for our purposes this is the same thing as jQuery's $(document).ready(...) 161 | document.addEventListener('DOMContentLoaded', function () { 162 | // intializes the Hootsuite JS SDK 163 | hsp.init({ 164 | useTheme: true 165 | }); 166 | 167 | loadTopBars(); 168 | bindApiButtons(); 169 | googleAuthInit(); 170 | 171 | var socket = io(); 172 | 173 | socket.on('stream update', function (msg) { 174 | // inserts incoming messages by copying previous messages and changing contents 175 | var parent = getSingleElementByClassName('hs_messages'); 176 | var child = parent.insertBefore(getSingleElementByClassName('hs_message').cloneNode(true), parent.firstChild); 177 | child.getElementsByClassName('hs_postBody')[0].textContent = msg; 178 | }); 179 | 180 | hsp.bind('refresh', function () { 181 | // You can do a complex refresh here, 182 | // for example only reload certain parts of your app. 183 | // In this example we'll resend the fake messages that get sent on connection 184 | 185 | var messages = document.getElementsByClassName('hs_message'); 186 | // removes all messages except for one to use it as a template for adding more 187 | var initialLength = messages.length; 188 | for (var i = 0; i < initialLength-1; i++) { 189 | messages[0].remove(); 190 | } 191 | 192 | // tells the server to re-serve the fake stream updates 193 | socket.emit('restart'); 194 | }); 195 | }); 196 | --------------------------------------------------------------------------------