`);
if (row.children[VoteHeaderIndex].textContent.includes('🔒')) {
if (GM_getValue(STORAGE_VARS.IsVIP)) {
downvoteButton.setAttribute('title', 'This segment is locked by a VIP, be sure to discuss first before downvoting this segment');
downvoteButton.style.color = '#ffc83d';
}
else {
downvoteButton.setAttribute('title', 'This segment is locked by a VIP');
downvoteButton.classList.add('disabled');
}
}
else {
downvoteButton.setAttribute('title', 'Downvote this segment');
}
downvoteButton.addEventListener('click', () => {
if (!downvoteButton.classList.contains('disabled') && confirm('Confirm downvoting?')) {
const segmentId = row.querySelector('textarea[name="UUID"]')?.value;
DisableVoteButtons();
downvoteButton.classList.add('loading');
SendVoteSegment(segmentId, VOTE_SEG_OPTIONS.Down, () => {
EnableVoteButtons();
upvoteButton.style.color = '';
downvoteButton.classList.remove('loading');
downvoteButton.style.color = 'red';
}, () => {
EnableVoteButtons();
downvoteButton.classList.remove('loading');
});
}
});
// Undo vote button
const undovoteButton = votingContainer.appendFromString(`
${ROTATE_LEFT_ICON}
`);
undovoteButton.addEventListener('click', () => {
if (!undovoteButton.classList.contains('disabled') && confirm('Confirm undo vote?')) {
const segmentId = row.querySelector('textarea[name="UUID"]')?.value;
DisableVoteButtons();
undovoteButton.classList.add('loading');
SendVoteSegment(segmentId, VOTE_SEG_OPTIONS.Undo, () => {
EnableVoteButtons();
upvoteButton.style.color = '';
downvoteButton.style.color = '';
undovoteButton.classList.remove('loading');
}, () => {
EnableVoteButtons();
undovoteButton.classList.remove('loading');
});
}
});
row.children[VoteHeaderIndex].style.minWidth = '6.7em'; // Make room for voting buttons
function DisableVoteButtons() {
upvoteButton.classList.add('disabled');
downvoteButton.classList.add('disabled');
undovoteButton.classList.add('disabled');
}
function EnableVoteButtons() {
upvoteButton.classList.remove('disabled');
downvoteButton.classList.remove('disabled');
undovoteButton.classList.remove('disabled');
}
}
}
/**
* Add category change button to segment row if user is VIP or user created the segment
* @param {HTMLElement} row
*/
function AddCategoryChangeButtonToRow(row) {
if (!row.children[CategoryHeaderIndex].querySelector('.changeSegmentBtn') && (GM_getValue(STORAGE_VARS.IsVIP) || row.querySelector('textarea[name="UserID"]')?.value === GM_getValue(STORAGE_VARS.PublicUserID))) {
row.children[CategoryHeaderIndex].appendChild(document.createElement('br'));
const categoryChangeButton = row.children[CategoryHeaderIndex].appendFromString('');
categoryChangeButton.addEventListener('click', () => {
categoryChangeButton.classList.add('disabled');
const segmentId = row.querySelector('textarea[name="UUID"]')?.value;
const category = CATEGORIES_VALUES.find(c => row.children[CategoryHeaderIndex].textContent.toLowerCase().includes(c));
ShowCategoryChangeModal(segmentId, category, () => {
categoryChangeButton.classList.remove('disabled');
});
});
}
}
/**
* Show a modal with a list of categories to choose from and a button to save the category
* @param {string} segmentId UUID of the segment
* @param {string} category current category of the segment
* @param {() => void|undefined} onClosed function to call when the modal is closed
*/
function ShowCategoryChangeModal(segmentId, category, onClosed) {
// Create a modal
const modal = new Modal;
modal.Title = 'Change category';
// Add categories to modal
modal.Body.appendFromString('');
const categorySelect = modal.Body.appendFromString('');
CATEGORIES_VALUES.forEach(cat => {
const option = categorySelect.appendFromString(``);
if (category === cat) {
option.selected = true;
option.disabled = true;
}
});
// Assign close function to modal
modal.OnClosed = onClosed;
// Assign save function to modal
modal.AddButton('Save changes', (button) => {
if (confirm(`Confirm changing category from "${category}" to "${categorySelect.value}"?`)) {
button.classList.add('disabled');
const spinner = button.appendFromString('');
SendCategoryUpdate(segmentId, categorySelect.value, modal.CloseModal.bind(modal), () => {
spinner.remove();
button.classList.remove('disabled');
});
}
});
}
/**
* Show a modal to lock categories of a video
* @param {string} videoID
* @param {() => void|undefined} onClosed function to call when the modal is closed
*/
function ShowLockCategoriesModal(videoID, onClosed) {
// Create a modal
const modal = new Modal;
modal.Title = 'Lock/Unlock categories for ' + videoID;
// Add categories to modal
modal.Body.appendFromString('
');
const reasonTextarea = modal.Body.appendFromString('');
// Assign close function to modal
modal.OnClosed = onClosed;
// Add unlock button to modal
modal.AddButton('🔓 Unlock', (button) => {
const categories = [...modal.Body.querySelectorAll('#modal_categories_container input[type="checkbox"]:checked')].map(c => c.value);
const actionTypes = [...modal.Body.querySelectorAll('#modal_action_types_container input[type="checkbox"]:checked')].map(t => t.value);
if (categories.length === 0 || actionTypes.length === 0) {
ShowToast('Please select at least one category and action type to unlock.', TOAST_TYPE.Warning);
}
else if (confirm('Confirm unlocking these categories?\n\n' + categories.join(', '))) {
button.classList.add('disabled');
const spinner = button.appendFromString('');
SendUnlockCategories(videoID, categories, actionTypes, modal.CloseModal.bind(modal), () => {
spinner.remove();
button.classList.remove('disabled');
});
}
});
// Add lock button to modal
modal.AddButton('🔒 Lock', (button) => {
// Bootstrap will clone the `categoriesContainer` and `actionTypesContainer` (I think), therefore we need to get the values by querying the body
const categories = [...modal.Body.querySelectorAll('#modal_categories_container input[type="checkbox"]:checked')].map(c => c.value);
const actionTypes = [...modal.Body.querySelectorAll('#modal_action_types_container input[type="checkbox"]:checked')].map(t => t.value);
const reason = reasonTextarea.value;
if (categories.length === 0 || actionTypes.length === 0) {
ShowToast('Please select at least one category and action type to lock.', TOAST_TYPE.Warning);
}
else if (confirm('Confirm locking these categories?\n\n' + categories.join(', '))) {
button.classList.add('disabled');
const spinner = button.appendFromString('');
SendLockCategories(videoID, categories, actionTypes, reason, modal.CloseModal.bind(modal), () => {
spinner.remove();
button.classList.remove('disabled');
});
}
});
}
/**
* Show a confirmation modal
* @param {string} title modal's title
* @param {string} message modal's message
* @param {() => void|undefined} onAccept function to be called when user press Yes button
* @param {() => void|undefined} onDecline function to be called when user press No button
* @returns {[Modal, HTMLButtonElement, HTMLButtonElement]} the modal instance, accept and decline buttons
*/
function ShowConfirmModal(title, message, onAccept, onDecline) {
const modal = new Modal;
modal.Title = title;
modal.Body.appendFromString(`
`);
this._title = this._modal.querySelector('.modal-title');
this._modalBody = this._modal.querySelector('.modal-body');
document.body.appendChild(this._modal);
this._bootstrapModal = new bootstrap.Modal(this._modal);
this._bootstrapModal.show();
this._modal.addEventListener('hide.bs.modal', () => {
if (this._onClosed) this._onClosed();
});
this._modal.addEventListener('hidden.bs.modal', this._modal.remove);
}
CloseModal() {
this._bootstrapModal.hide();
}
/**
* Add a button at the bottom of the modal (primary buttons)
* @param {string} text Button text
* @param {(button: HTMLButtonElement) => any} action Action to perform when button is clicked
* @return {HTMLButtonElement} The button element
*/
AddButton(text, action) {
const button = this._modal.querySelector('.modal-footer').appendFromString(``);
button.addEventListener('click', () => action(button));
return button;
}
/**
* @param {string} title
*/
set Title(title) {
this._title.append(title);
}
/**
* @param {() => any} onClosed
*/
set OnClosed(onClosed) {
this._onClosed = onClosed;
}
get Body() { return this._modalBody; }
}
/**
* Display a toast message (duh)
* @param {string} message message to display
* @param {TOAST_TYPE} type toast type (normal, warning, danger)
*/
function ShowToast(message, type = TOAST_TYPE.Normal) {
const toast = TOASTS_CONTAINER.appendFromString(`
${message}
`);
switch (type) {
case TOAST_TYPE.Normal:
toast.classList.add('text-white', 'bg-dark');
break;
case TOAST_TYPE.Warning:
toast.classList.add('text-black', 'bg-warning');
break;
case TOAST_TYPE.Danger:
toast.classList.add('text-white', 'bg-danger');
break;
default:
break;
}
new bootstrap.Toast(toast).show();
}
/**
* Send request to API for voting on segments
* @param {string} uuid
* @param {VOTE_SEG_OPTIONS} voteID
* @param {() => void|undefined} onSuccess function to call when the request is successful
* @param {() => void|undefined} onError function to call when the request returns an error or there is an error with input
*/
function SendVoteSegment(uuid, voteID, onSuccess, onError) {
const userID = GM_getValue(STORAGE_VARS.PrivateUserID);
if (!VerifyUUID(uuid)) {
ShowToast(`Invalid segment ID: "${uuid}"`, TOAST_TYPE.Warning);
if (onError) onError();
}
else if (Object.values(VOTE_SEG_OPTIONS).indexOf(voteID) === -1) {
ShowToast(`Invalid vote ID: "${voteID}"`, TOAST_TYPE.Warning);
if (onError) onError();
}
else if (!VerifyPrivateUserID(userID)) {
ShowToast(`Invalid user ID: "${userID}"`, TOAST_TYPE.Warning);
if (onError) onError();
}
else {
GM_xmlhttpRequest({
method: 'POST',
url: `https://sponsor.ajay.app/api/voteOnSponsorTime?UUID=${uuid}&userID=${userID}&type=${voteID}`,
responseType: 'json',
timeout: 5000,
onload: function(response) {
switch (response.status) {
case 403:
ShowToast('Vote is rejected\n\n' + response.response.message, TOAST_TYPE.Danger);
if (onError) onError();
break;
case 400:
ShowToast('Failed to vote on the segment. Please check the segment info and your User ID\n\n' +
'UUID: ' + uuid + '\n' +
'Type: ' + voteID,
TOAST_TYPE.Danger);
if (onError) onError();
break;
case 200:
ShowToast('Voted!');
if (onSuccess) onSuccess();
break;
default:
ShowToast('Failed to send the request, something might be wrong with the server.', TOAST_TYPE.Warning);
if (onError) onError();
break;
}
},
onerror: function() {
ShowToast('Failed to send the request, something might be wrong with the server or your internet is 💩.', TOAST_TYPE.Warning);
if (onError) onError();
}
});
}
}
/**
* Update category of a segment
* @param {string} uuid segment UUID
* @param {string} category the new category of the segment
* @param {() => void|undefined} onSuccess function to call when the request is successful
* @param {() => void|undefined} onError function to call when the request returns an error or there is an error with input
*/
function SendCategoryUpdate(uuid, category, onSuccess, onError) {
const userID = GM_getValue(STORAGE_VARS.PrivateUserID);
if (!VerifyUUID(uuid)) {
ShowToast(`Invalid segment ID: "${uuid}"`, TOAST_TYPE.Warning);
if (onError) onError();
}
else if (!(category in CATEGORIES)) {
ShowToast(`Invalid category name: "${category}"`, TOAST_TYPE.Warning);
if (onError) onError();
}
else if (!VerifyPrivateUserID(userID)) {
ShowToast(`Invalid user ID: "${userID}"`, TOAST_TYPE.Warning);
if (onError) onError();
}
else {
GM_xmlhttpRequest({
method: 'POST',
url: `https://sponsor.ajay.app/api/voteOnSponsorTime?UUID=${uuid}&userID=${userID}&category=${category}`,
responseType: 'json',
timeout: 5000,
onload: function(response) {
switch (response.status) {
case 403:
ShowToast('Update is rejected\n\n' + response.response.message, TOAST_TYPE.Danger);
if (onError) onError();
break;
case 400:
ShowToast('Failed to update the category. Please check the segment info and your User ID\n\n' +
'UUID: ' + uuid + '\n' +
'Category: ' + category,
TOAST_TYPE.Danger);
if (onError) onError();
break;
case 200:
ShowToast('Updated!');
if (onSuccess) onSuccess();
break;
default:
ShowToast('Failed to send the request, something might be wrong with the server.', TOAST_TYPE.Warning);
if (onError) onError();
break;
}
},
onerror: function() {
ShowToast('Failed to send the request, something might be wrong with the server or your internet is 💩.', TOAST_TYPE.Warning);
if (onError) onError();
}
});
}
}
/**
* Send request to lock categories for a video
* @param {string} videoID
* @param {string[]} categories an array of categories being locked
* @param {string[]} actionTypes an array of action types being locked
* @param {string} reason why these categories are locked
* @param {() => void|undefined} onSuccess function to call when the request is successful
* @param {() => void|undefined} onError function to call when the request returns an error or there is an error with input
*/
function SendLockCategories(videoID, categories, actionTypes, reason, onSuccess, onError) {
const userID = GM_getValue(STORAGE_VARS.PrivateUserID);
const invalidCategories = categories.filter(c => !(c in CATEGORIES));
const invalidActionTypes = actionTypes.filter(t => !(t in ACTION_TYPES));
if (!VerifyPrivateUserID(userID)) {
ShowToast(`Invalid user ID: "${userID}"`, TOAST_TYPE.Warning);
if (onError) onError();
}
else if (invalidCategories.length > 0) {
ShowToast('Invalid categories: ' + invalidCategories.join(', '), TOAST_TYPE.Warning);
if (onError) onError();
}
else if (invalidActionTypes.length > 0) {
ShowToast('Invalid action types: ' + invalidActionTypes.join(', '), TOAST_TYPE.Warning);
if (onError) onError();
}
else {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://sponsor.ajay.app/api/lockCategories',
data: JSON.stringify({ videoID, userID, categories, actionTypes, reason }),
headers: { 'Content-Type': 'application/json' },
timeout: 5000,
onload: function(response) {
switch (response.status) {
case 400:
ShowToast('Failed to lock categories. Please check these info and your User ID\n\n' +
'Video ID: ' + videoID + '\n' +
'Categories: ' + categories.join(', ') + '\n' +
'Action types: ' + actionTypes.join(', ') + '\n' +
'Reason: ' + reason,
TOAST_TYPE.Danger);
if (onError) onError();
break;
case 403:
ShowToast('Lock is rejected. You are not a VIP', TOAST_TYPE.Danger);
if (onError) onError();
break;
case 200:
ShowToast('Locked!');
if (onSuccess) onSuccess();
break;
default:
ShowToast('Failed to send the request, something might be wrong with the server.', TOAST_TYPE.Warning);
if (onError) onError();
break;
}
},
onerror: function() {
ShowToast('Failed to send the request, something might be wrong with the server or your internet is 💩.', TOAST_TYPE.Warning);
if (onError) onError();
}
});
}
}
/**
* Send request to unlock categories for a video
* @param {string} videoID
* @param {string[]} categories an array of categories being locked
* @param {string[]} actionTypes an array of action types being locked
* @param {() => void|undefined} onSuccess function to call when the request is successful
* @param {() => void|undefined} onError function to call when the request returns an error or there is an error with input
*/
function SendUnlockCategories(videoID, categories, actionTypes, onSuccess, onError) {
const userID = GM_getValue(STORAGE_VARS.PrivateUserID);
const invalidCategories = categories.filter(c => !(c in CATEGORIES));
const invalidActionTypes = actionTypes.filter(t => !(t in ACTION_TYPES));
if (!VerifyPrivateUserID(userID)) {
ShowToast(`Invalid user ID: "${userID}"`, TOAST_TYPE.Warning);
if (onError) onError();
}
else if (invalidCategories.length > 0) {
ShowToast('Invalid categories: ' + invalidCategories.join(', '), TOAST_TYPE.Warning);
if (onError) onError();
}
else if (invalidActionTypes.length > 0) {
ShowToast('Invalid action types: ' + invalidActionTypes.join(', '), TOAST_TYPE.Warning);
if (onError) onError();
}
else {
GM_xmlhttpRequest({
method: 'DELETE',
url: 'https://sponsor.ajay.app/api/lockCategories',
data: JSON.stringify({ videoID, userID, categories, actionTypes }),
headers: { 'Content-Type': 'application/json' },
responseType: 'json',
timeout: 5000,
onload: function(response) {
switch (response.status) {
case 400:
ShowToast('Failed to unlock categories. Please check these info and your User ID\n\n' +
'Video ID: ' + videoID + '\n' +
'Categories: ' + categories.join(', ') + '\n' +
'Action types: ' + actionTypes.join(', '),
TOAST_TYPE.Danger);
if (onError) onError();
break;
case 403:
ShowToast('Unlock is rejected. You are not a VIP', TOAST_TYPE.Danger);
if (onError) onError();
break;
case 200:
ShowToast(response.response.message);
if (onSuccess) onSuccess();
break;
default:
ShowToast('Failed to send the request, something might be wrong with the server.', TOAST_TYPE.Warning);
if (onError) onError();
break;
}
},
onerror: function() {
ShowToast('Failed to send the request, something might be wrong with the server or your internet is 💩.', TOAST_TYPE.Warning);
if (onError) onError();
}
});
}
}
/**
* Send request to remove all segments on a video
* @param {string} videoID
* @param {() => void|undefined} onSuccess function to call when the request is successful
* @param {() => void|undefined} onError function to call when the request returns an error or there is an error with input
*/
function SendPurgeSegments(videoID, onSuccess, onError) {
const userID = GM_getValue(STORAGE_VARS.PrivateUserID);
if (!VerifyPrivateUserID(userID)) {
ShowToast(`Invalid user ID: "${userID}"`, TOAST_TYPE.Warning);
if (onError) onError();
}
else {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://sponsor.ajay.app/api/purgeAllSegments',
data: JSON.stringify({ videoID, userID }),
headers: { 'Content-Type': 'application/json' },
timeout: 5000,
onload: function(response) {
switch (response.status) {
case 400:
ShowToast('Failed to purge segments. Please check these info and your User ID\n\n' +
'Video ID: ' + videoID,
TOAST_TYPE.Danger);
if (onError) onError();
break;
case 403:
ShowToast('Purge is rejected. You are not a VIP', TOAST_TYPE.Danger);
if (onError) onError();
break;
case 200:
ShowToast('Purged all segments!');
if (onSuccess) onSuccess();
break;
default:
ShowToast('Failed to send the request, something might be wrong with the server.', TOAST_TYPE.Warning);
if (onError) onError();
break;
}
},
onerror: function() {
ShowToast('Failed to send the request, something might be wrong with the server or your internet is 💩.', TOAST_TYPE.Warning);
if (onError) onError();
}
});
}
}
/**
* Send request to receive information about the current user and store it in the GM storage
* @param {({publicUserID: string, username: string, isVIP: boolean}) => void} onSuccess
* @param {() => void} onError function to call when the request returns an error or there is an error with input
*/
function SendGetUserInfo(onSuccess, onError) {
const userID = GM_getValue(STORAGE_VARS.PrivateUserID);
if (!VerifyPrivateUserID(userID)) {
ShowToast(`Invalid user ID: "${userID}"`, TOAST_TYPE.Warning);
if (onError) onError();
}
else {
GM_xmlhttpRequest({
method: 'GET',
url: `https://sponsor.ajay.app/api/userInfo?userID=${userID}&values=["userID","userName","vip"]`,
responseType: 'json',
timeout: 5000,
onload: function(response) {
switch (response.status) {
case 400:
ShowToast('Failed to get user info. Please check your User ID', TOAST_TYPE.Danger);
if (onError) onError();
break;
case 200:
GM_setValue(STORAGE_VARS.PublicUserID, response.response.userID);
GM_setValue(STORAGE_VARS.Username, response.response.userName);
GM_setValue(STORAGE_VARS.IsVIP, response.response.vip);
if (onSuccess) onSuccess({ publicUserID: response.response.userID, username: response.response.userName, isVIP: response.response.vip });
break;
default:
ShowToast('Failed to send the request, something might be wrong with the server.', TOAST_TYPE.Warning);
if (onError) onError();
break;
}
},
onerror: function() {
ShowToast('Failed to send the request, something might be wrong with the server or your internet is 💩.', TOAST_TYPE.Warning);
if (onError) onError();
}
});
}
}
// Utilities
function VerifyUUID(uuid) {
return /^[a-f0-9]{64,65}$/.test(uuid);
}
function VerifyPrivateUserID(userID) {
return userID && userID.length >= 32;
}
// Event listener for new segments
document.addEventListener('newSegments', (event) => Main());
})();