// I'm not a React developer. Be advised! // If you are, and want to rewrite this the "right way", go ahead and do it :) // This is plain Javascript and only requires basic calls to implement customizations // Do simple XHR requests to the API on Mastodon // make sure you control errors correctly, as a Mastodon Server might not setup CORS correctly function mastodon_get(path, payload, callbk) { return mastodon_request('GET', path, payload, callbk); } function mastodon_post(path, payload, callbk) { return mastodon_request('POST', path, payload, callbk); } function mastodon_request(method, path, payload, callbk) { payload = payload || null; var url = 'https://'+fediloveApi.getCurrentInstance()+path; var oReq = new XMLHttpRequest(); oReq.addEventListener("load", function() { callbk(this.responseText); }); oReq.open(method, url); oReq.setRequestHeader('Authorization', 'Bearer '+fediloveApi.getAccessToken()); oReq.send(payload); } function api_status_fav(dom, status_id) { var dislike = false; var path = `/api/v1/statuses/${status_id}/favourite`; if (dom.getAttribute('data-liked') === "1") { path = `/api/v1/statuses/${status_id}/unfavourite`; dislike = true; } mastodon_post(path, {}, function(data) { // to-do: check "data" if the POST succeded (for now we expect it did xD) if (!dislike) { $(dom).addClass('liked-msg'); $(dom).attr('data-liked', 1); dom.style.animation = "spin2 .5s linear 1"; } else { $(dom).removeClass('liked-msg'); $(dom).attr('aria-label', '0 '+$(dom).attr('aria-label')); $(dom).removeAttr('data-liked'); dom.style.animation = undefined; } }); } // The api to send a message on the current chat thread has been improved a lot // and now is much more rock-solid, and we reuse the React functions function api_send_message() { // the message is composed from the current chat Acct (@user@domain) + the compose textarea value const text = fediloveData.chatAvatarCache.acct + ' ' + $('div#chat-compose-global textarea').val(); //async function postStatus(realm, text, inReplyToId, mediaIds, sensitive, spoilerText, visibility, mediaDescriptions, inReplyToUuid, poll, mediaFocalPoints) const lastStatus = fediloveApi.getChatLastMessageId(true); fediloveFunctions.postStatus(lastStatus.id, text, lastStatus.id, [], false, undefined, 'direct', undefined, lastStatus.uuid, undefined, undefined); // empty the current compose box $('div#chat-compose-global textarea').val(''); // scroll to the end as all chat Apps do fediloveUI.scrollChatToLastItem(); } var fediloveUI = { scrollChatToLastItem: function() { const startLen = $('div.the-list > div').length; var count = 0; var _this = setInterval(function() { count++; var newLen = $('div.the-list > div').length; if (count >= 100 || newLen != startLen) { document.querySelector('div.the-list > div:last-child').scrollIntoView(); clearInterval(_this); } }, 150); }, paintChatAvatarAndName: function(accid, acct, avatar, name) { accid = accid || null; acct = acct || null; avatar = avatar || null; name = name || null; if ((accid+acct+avatar+name) === 0) { accid = 0; avatar = '/missing.png'; name = '...'; } else { fediloveData.chatAvatarCache = { id: accid, acct: acct, avatar: avatar, name: name }; // add domain part on acct on painting it to be clear if (acct.split('@').length-1 === 1) { acct += `@${fediloveApi.getCurrentInstance()}`; } } // to-do: check is it XSS safe to add it like this :) ?? $('div#chat-party-global > div#image > img').attr('src', avatar); $('div#chat-party-global > div#name > a').html(name); $('div#chat-party-global > div#name > span').text(acct); if (accid != 0) $('div#chat-party-global > div#name > a').attr('href', `/accounts/${accid}`); } }; // objects to access from React code var fediloveApi = { getChatMessageId: function() { var parts = window.location.pathname.split('/'); return parts[parts.length-1]; }, getChatLastMessageId: function(andUuid) { andUuid = andUuid || false; const lastUuid = $('div.the-list article.status-article').last().attr('id'); if (lastUuid === undefined) { return undefined; } const parts = lastUuid.split('/'); return andUuid? { id: parts[parts.length-1], uuid: lastUuid } : parts[parts.length-1]; }, getAccessToken: function() { var instance = fediloveApi.getCurrentInstance(); return JSON.parse(localStorage.store_loggedInInstances)[instance].access_token; }, getCurrentInstance: function() { return JSON.parse(localStorage.store_currentInstance); } }; var fediloveFunctions = {}; var fediloveData = { chatAvatarCache: undefined, gotEmojifyTextFunction: false, composeTxtKeypressEvent: false }; var fediloveEvents = { onGotEmojifyTextFunction: function() { fediloveData.gotEmojifyTextFunction = true; }, onChatGetData: function(data) { // dont do anything if avatar and name is cached if (fediloveData.chatAvatarCache !== undefined) return; // waits for the React code to call the "onGotEmojifyTextFunction" so we can use it var waitForEmojifyAndDo = function(accid, acct, avatar, dname, emojis) { var count = 0; var _this = setInterval(function() { if (count > 100) { clearInterval(_this); return; } if (fediloveData.gotEmojifyTextFunction) { fediloveUI.paintChatAvatarAndName(accid, acct, avatar, fediloveFunctions.emojifyText(dname, emojis)); clearInterval(_this); } count++; }, 150); }; if (data) { // if the message is mine, search which account is doing it for, // and do the same with the party's data if (localStorage.store_userAccountId == data.account.id) { var account_id = data.in_reply_to_account_id; if (account_id === null && data.mentions.length > 0) { account_id = data.mentions[0].id; } if (account_id === null || account_id === undefined) return; mastodon_get(`/api/v1/accounts/${account_id}`, {}, function(newData) { var json = JSON.parse(newData); if (json !== undefined) { waitForEmojifyAndDo( account_id, `@${json.acct}`, json.avatar, json.display_name, json.emojis ); } }); } else { // this means the message we are loading is from the other party, so we continue as normal waitForEmojifyAndDo( data.account.id, `@${data.account.acct}`, data.account.avatar, data.account.display_name, data.account.emojis ); } } }, onEmojiPicked: function(emoji) { var $txt = $("#chat-compose-global textarea"); var caretPos = $txt[0].selectionStart; var textAreaTxt = $txt.val(); $txt.val(textAreaTxt.substring(0, caretPos) + emoji + textAreaTxt.substring(caretPos)); }, onNewNotification: function (dataItems) { if (window.location.pathname.startsWith('/statuses/')) { if (fediloveFunctions.updateStatusAndThread !== undefined) { fediloveFunctions.updateStatusAndThread( fediloveApi.getCurrentInstance(), fediloveApi.getAccessToken(), window.location.pathname, fediloveApi.getChatMessageId() ); fediloveUI.scrollChatToLastItem(); } } } }; // this is our URL-based customizations made by JavaScript var intervalChatCssChange = null; function fedilove_customization() { document.body.classList = ''; document.querySelector('.main-content').classList = "main-content"; document.querySelector('#chat-compose-global').style = 'visibility: collapse'; document.querySelector('#chat-party-hide').style = 'display: none !important'; document.querySelector('nav#main-nav > ul.main-nav-ul').style = ''; if (window.location.pathname.startsWith('/statuses/')) { $('div.main-content').addClass('chat'); $('body').addClass('chat'); document.querySelector('#chat-compose-global').style = ''; document.querySelector('#chat-party-hide').style = ''; document.querySelector('nav#main-nav > ul.main-nav-ul').style = 'display: none !important'; if (!fediloveData.composeTxtKeypressEvent) { $('div#chat-compose-global textarea').keypress(function(e) { const keycode = (e.keyCode ? e.keyCode : e.which); if (keycode == '13') { if (!e.ctrlKey && !e.shiftKey) { e.stopPropagation(); e.preventDefault(); setTimeout(function() { api_send_message() }, 100); return true; } else { if (!e.shiftKey && e.ctrlKey) { $(this).val($(this).val()+'\n'); } } } }); fediloveData.composeTxtKeypressEvent = true; } // ******* // load cached avatars or paint it empty (automated process after this will fill it) if (fediloveData.chatAvatarCache !== undefined) { fediloveUI.paintChatAvatarAndName( fediloveData.chatAvatarCache.id, fediloveData.chatAvatarCache.acct, fediloveData.chatAvatarCache.avatar, fediloveData.chatAvatarCache.name ); } else { fediloveUI.paintChatAvatarAndName(); } // add some animations var style = document.createElement('style'); style.innerHTML = '@keyframes spin2 { 100% { transform:rotate(-360deg); } }'; document.body.appendChild(style); // this function changes the css class on articles (messages) // that match the given account_id var makeMessageUIModifications = function(account_id) { if (intervalChatCssChange !== null) return; var theint = 150; setInterval(function() { intervalChatCssChange = this; // paint MY messages as mine $('div.main-content.chat article.status-article').each(function(i) { if ($(this).find('a.status-author-name').attr('href').endsWith(`/accounts/${account_id}`)) { $(this).addClass('mymsg'); theint = 250; } else { $(this).addClass('partymsg'); } }); // paint LIKES $('a.status-favs-reblogs.status-favs').each(function(i) { // easy, aria-label contains the times this status was fav // so, we use this as the input source to search for "0", // if no "0" found in text, we mark element as liked so we can apply styles and "undo like" function if ($(this).attr('aria-label').search(/0/) == -1) { $(this).addClass('liked-msg'); $(this).attr('data-liked', 1); } }); // remove liking functionality from our own messages $('div.the-list article.status-article.mymsg a.status-favs-reblogs').each(function(i) { $(this).attr('onclick', 'return false;'); }); // resize likes acording to the outer parent $('div.main-content.chat div.the-list article.status-article').each(function(e) { $(this).find('div.like-div').width($(this).width()); }); }, theint); }; // get the userAccountId to check against the href /account/NUM on the messages // so we can apply different style to my messages if (localStorage.store_userAccountId === undefined) { mastodon_get('/api/v1/accounts/verify_credentials', {}, function(data) { var json = JSON.parse(data); if (json !== undefined) { localStorage.store_userAccountId = json.id; makeMessageUIModifications(json.id); } }); } else { makeMessageUIModifications(localStorage.store_userAccountId); } } else if (window.location.pathname == '/direct') { $('div.main-content').addClass('direct'); } else if (window.location.pathname.startsWith('/notifications/mentions')) { $('nav.notification-filters li > a.focus-fix').attr('onclick', 'return false;'); } if (!window.location.pathname.startsWith('/statuses/')) { fediloveData.chatAvatarCache = undefined; } } // we inject this script.js into the React framework at timelines.js // Watch for URL changes every 1/2 seconds (this is efficient, don't worry about it!) // and dispatch a call to "fedilove_customization" to load page customizations depending on URL context var theurl = null; setInterval(function() { var newurl = window.location.pathname; if (newurl != theurl) { fedilove_customization(); theurl = newurl; } }, 250);