Sanitized public release v1.0.3

Source-Tag: v1.0.3
Manifest-SHA256: b7b09bf48f9e092ed8ad82f99c0319d6c3837d0a1549bd0cc4a374276c0f3896
This commit is contained in:
NIA Sanitized Release Publisher
2026-07-20 22:35:36 +00:00
commit 7141326ea0
41 changed files with 6397 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
<section class="neo-colour-settings" ng-show="neoSettingsTab === 'colours'" role="tabpanel" aria-label="Colours">
<div class="neo-settings-toolbar">
<p>These values are read from and saved to WeeChat. Role colours update <code>irc.color.nick_prefixes</code>.</p>
<button type="button" class="btn btn-default" ng-click="loadNeoWeeChatColours()" ng-disabled="neoWeeChatColours.loading">Refresh</button>
</div>
<p class="status" ng-bind="neoWeeChatColours.status"></p>
<h5>Chat</h5>
<div class="neo-colour-grid">
<label ng-repeat="item in neoWeeChatColours.items"><span ng-bind="item.label"></span><select class="form-control" ng-model="neoWeeChatColours.values[item.name]" ng-options="colour for colour in neoWeeChatColourChoices" ng-change="setNeoWeeChatColour(item.name)"></select><i class="neo-colour-swatch" ng-style="{'background-color': neoWeeChatColourPreview(neoWeeChatColours.values[item.name])}" title="{{neoWeeChatColours.values[item.name]}}"></i><code ng-bind="item.name"></code></label>
</div>
<h5>Nickname roles</h5>
<div class="neo-colour-grid">
<label><span>Operator</span><select class="form-control" ng-model="neoWeeChatColours.prefixes.o" ng-options="colour for colour in neoWeeChatColourChoices" ng-change="setNeoNickPrefixColour('o')"></select><i class="neo-colour-swatch" ng-style="{'background-color': neoWeeChatColourPreview(neoWeeChatColours.prefixes.o)}" title="{{neoWeeChatColours.prefixes.o}}"></i><code>mode o</code></label>
<label><span>Half-operator</span><select class="form-control" ng-model="neoWeeChatColours.prefixes.h" ng-options="colour for colour in neoWeeChatColourChoices" ng-change="setNeoNickPrefixColour('h')"></select><i class="neo-colour-swatch" ng-style="{'background-color': neoWeeChatColourPreview(neoWeeChatColours.prefixes.h)}" title="{{neoWeeChatColours.prefixes.h}}"></i><code>mode h</code></label>
<label><span>Voice</span><select class="form-control" ng-model="neoWeeChatColours.prefixes.v" ng-options="colour for colour in neoWeeChatColourChoices" ng-change="setNeoNickPrefixColour('v')"></select><i class="neo-colour-swatch" ng-style="{'background-color': neoWeeChatColourPreview(neoWeeChatColours.prefixes.v)}" title="{{neoWeeChatColours.prefixes.v}}"></i><code>mode v</code></label>
<label><span>Other prefixes</span><select class="form-control" ng-model="neoWeeChatColours.prefixes['*']" ng-options="colour for colour in neoWeeChatColourChoices" ng-change="setNeoNickPrefixColour('*')"></select><i class="neo-colour-swatch" ng-style="{'background-color': neoWeeChatColourPreview(neoWeeChatColours.prefixes['*'])}" title="{{neoWeeChatColours.prefixes['*']}}"></i><code>fallback *</code></label>
</div>
<h5>Channel nickname palette</h5>
<div class="neo-palette-setting"><input class="form-control" type="text" ng-model="neoWeeChatColours.values['weechat.color.chat_nick_colors']" ng-change="updateNeoPalettePreview()"><button type="button" class="btn btn-primary" ng-click="setNeoWeeChatColour('weechat.color.chat_nick_colors')">Save palette</button><div class="neo-palette-preview" aria-label="Nickname palette preview"><i ng-repeat="colour in neoWeeChatColours.palettePreview track by $index" ng-style="{'background-color': neoWeeChatColourPreview(colour)}" title="{{colour}}"></i></div><code>weechat.color.chat_nick_colors</code></div>
</section>
+102
View File
@@ -0,0 +1,102 @@
var neoColourOptions = [
{name: 'weechat.color.chat_nick_self', label: 'Your nickname', variable: '--neorelay-nick-self'},
{name: 'weechat.color.chat_nick', label: 'Regular nickname fallback', variable: '--neorelay-nick'},
{name: 'weechat.color.chat_nick_other', label: 'Other private nicknames', variable: '--neorelay-nick-other'},
{name: 'weechat.color.nicklist_away', label: 'Away / AFK', variable: '--neorelay-nick-away'},
{name: 'weechat.color.chat_highlight', label: 'Highlight foreground', variable: '--neorelay-chat-highlight'},
{name: 'weechat.color.chat_highlight_bg', label: 'Highlight background', variable: '--neorelay-chat-highlight-bg'},
{name: 'weechat.color.chat_time', label: 'Timestamp', variable: '--neorelay-chat-time'}
];
var neoPrefixOption = 'irc.color.nick_prefixes';
var neoPaletteOption = 'weechat.color.chat_nick_colors';
$scope.neoWeeChatColours = {loading: false, status: '', items: neoColourOptions, rows: {}, values: {}, prefixes: {}, prefixRaw: '', palettePreview: []};
$scope.neoWeeChatColourPreview = neoResolveWeeChatColour;
$scope.updateNeoPalettePreview = function() {
$scope.neoWeeChatColours.palettePreview = String($scope.neoWeeChatColours.values[neoPaletteOption] || '').split(',').map(function(value) { return value.trim(); }).filter(neoValidWeeChatColour);
};
function neoParsePrefixes(value) {
var result = {};
String(value || '').split(';').forEach(function(entry) {
var match = entry.match(/^([A-Za-z*]):([^;]+)$/);
if (match) result[match[1]] = match[2].replace(/^[*!/_|]+/, '');
});
return result;
}
function neoApplyRelayColours() {
neoColourOptions.forEach(function(item) {
var value = $scope.neoWeeChatColours.values[item.name];
document.documentElement.style.setProperty(item.variable, neoResolveWeeChatColour(value));
});
var prefixes = $scope.neoWeeChatColours.prefixes;
document.documentElement.style.setProperty('--neorelay-nick-op', neoResolveWeeChatColour(prefixes.o || prefixes.a || prefixes.q || prefixes['*']));
document.documentElement.style.setProperty('--neorelay-nick-halfop', neoResolveWeeChatColour(prefixes.h || prefixes['*']));
document.documentElement.style.setProperty('--neorelay-nick-voice', neoResolveWeeChatColour(prefixes.v || prefixes['*']));
}
$scope.loadNeoWeeChatColours = function() {
if (!$rootScope.connected || $scope.neoWeeChatColours.loading) return;
$scope.neoWeeChatColours.loading = true;
$scope.neoWeeChatColours.status = 'Loading WeeChat colours...';
var names = neoColourOptions.map(function(item) { return item.name; }).concat([neoPrefixOption, neoPaletteOption]);
Promise.all(names.map(function(name) { return connection.requestInfolist('option', 0, name); })).then(function(results) {
$scope.$applyAsync(function() {
results.forEach(function(rows) {
if (!rows[0]) return;
var row = neoNormalizeOption(rows[0]);
neoLoadedOptions[row.name] = row;
$scope.neoWeeChatColours.rows[row.name] = row;
$scope.neoWeeChatColours.values[row.name] = row.value;
});
$scope.neoWeeChatColours.prefixRaw = $scope.neoWeeChatColours.values[neoPrefixOption] || '';
$scope.neoWeeChatColours.prefixes = neoParsePrefixes($scope.neoWeeChatColours.prefixRaw);
$scope.updateNeoPalettePreview();
neoApplyRelayColours();
$scope.neoWeeChatColours.loading = false;
$scope.neoWeeChatColours.status = 'Loaded from WeeChat.';
});
}, function() {
$scope.$applyAsync(function() {
$scope.neoWeeChatColours.loading = false;
$scope.neoWeeChatColours.status = 'Unable to load WeeChat colours.';
});
});
};
$scope.setNeoWeeChatColour = function(name) {
var row = $scope.neoWeeChatColours.rows[name];
var value = $scope.neoWeeChatColours.values[name];
neoWriteOption(row, value, function(updated) {
$scope.neoWeeChatColours.values[name] = updated.value;
if (name === neoPaletteOption) $scope.updateNeoPalettePreview();
neoApplyRelayColours();
$scope.neoWeeChatColours.status = 'Saved ' + name + ' to WeeChat.';
}, function(message) { $scope.neoWeeChatColours.status = message; });
};
$scope.setNeoNickPrefixColour = function(mode) {
var row = $scope.neoWeeChatColours.rows[neoPrefixOption];
if (!row || !neoValidWeeChatColour($scope.neoWeeChatColours.prefixes[mode])) return;
var found = false;
var entries = String($scope.neoWeeChatColours.prefixRaw || '').split(';').filter(Boolean).map(function(entry) {
if (entry.slice(0, 2) === mode + ':') {
found = true;
return mode + ':' + $scope.neoWeeChatColours.prefixes[mode];
}
return entry;
});
if (!found) entries.push(mode + ':' + $scope.neoWeeChatColours.prefixes[mode]);
var value = entries.join(';');
neoWriteOption(row, value, function(updated) {
$scope.neoWeeChatColours.prefixRaw = updated.value;
$scope.neoWeeChatColours.values[neoPrefixOption] = updated.value;
$scope.neoWeeChatColours.prefixes = neoParsePrefixes(updated.value);
neoApplyRelayColours();
$scope.neoWeeChatColours.status = 'Saved IRC prefix colours to WeeChat.';
}, function(message) { $scope.neoWeeChatColours.status = message; });
};
$scope.$watch(function() { return $rootScope.connected; }, function(connected) {
if (connected) $timeout($scope.loadNeoWeeChatColours, 250);
});
+9
View File
@@ -0,0 +1,9 @@
<div class="command-menu" ng-if="commandMenuOpen" role="listbox" id="{{inputId}}-command-menu">
<button type="button" class="command-menu-item" ng-repeat="item in commandMenuMatches track by item.command" ng-class="{selected: $index === commandMenuIndex}" id="{{inputId}}-command-{{$index}}" role="option" aria-selected="{{$index === commandMenuIndex}}" ng-mousedown="$event.preventDefault()" ng-mouseenter="commandMenuIndex = $index" ng-click="selectCommand(item)">
<span class="command-menu-name">/{{item.command}}</span>
<span class="command-menu-args" ng-bind="item.args"></span>
<span class="command-menu-description" ng-bind="item.description"></span>
</button>
<div class="command-menu-empty" ng-if="commandMenuLoading">Loading commands...</div>
<div class="command-menu-empty" ng-if="!commandMenuLoading && !commandMenuMatches.length">No matching commands</div>
</div>
+146
View File
@@ -0,0 +1,146 @@
var commandHooks = null;
var commandHooksRequest = null;
var commandMenuGeneration = 0;
var suppressCommandMenuKeypress = false;
var serverCommandFallbacks = [
{command:'defcon',args:'[<level>]',description:'Set or show Ergo emergency restrictions',insertion:'/quote DEFCON',serverCommand:true},
{command:'deoper',args:'',description:'Remove IRC operator privileges',insertion:'/quote DEOPER',serverCommand:true},
{command:'dline',args:'[<duration>] <ip>/<net> [<reason>] || LIST',description:'Add or list IP/network bans',insertion:'/quote DLINE',serverCommand:true},
{command:'kline',args:'[<duration>] <mask> [<reason>] || LIST',description:'Add or list client-mask bans',insertion:'/quote KLINE',serverCommand:true},
{command:'uban',args:'ADD|DEL|LIST|INFO <target>',description:'Manage unified bans',insertion:'/quote UBAN',serverCommand:true},
{command:'undline',args:'<ip>/<net>',description:'Remove an IP/network ban',insertion:'/quote UNDLINE',serverCommand:true},
{command:'unkline',args:'<mask>',description:'Remove a client-mask ban',insertion:'/quote UNKLINE',serverCommand:true}
];
var mergeCommandHooks = function(hooks) {
var unique = Object.create(null);
var hasOwn = Object.prototype.hasOwnProperty;
hooks.forEach(function(hook) {
var key = String(hook.command || '').toLowerCase();
if (key && (!hasOwn.call(unique, key) || hook.priority > unique[key].priority)) {
unique[key] = Object.assign({}, hook, {command:key});
}
});
serverCommandFallbacks.forEach(function(hook) {
if (!hasOwn.call(unique, hook.command)) unique[hook.command] = hook;
});
return Object.keys(unique).map(function(key){return unique[key];}).sort(function(left,right){return left.command.localeCompare(right.command);});
};
$scope.commandMenuOpen = false;
$scope.commandMenuLoading = false;
$scope.commandMenuMatches = [];
$scope.commandMenuIndex = 0;
var closeCommandMenu = function() {
$scope.commandMenuOpen = false;
$scope.commandMenuLoading = false;
$scope.commandMenuMatches = [];
$scope.commandMenuIndex = 0;
};
var updateCommandMenu = function() {
var match = /^\/([^\s]*)$/.exec($scope.command || '');
if (!match) {
closeCommandMenu();
return;
}
$scope.commandMenuOpen = true;
$scope.commandMenuIndex = 0;
if (commandHooks === null) {
$scope.commandMenuLoading = true;
if (commandHooksRequest === null) {
var requestGeneration = commandMenuGeneration;
commandHooksRequest = connection.requestCommandHooks().then(function(hooks) {
if (requestGeneration !== commandMenuGeneration) {
return;
}
commandHooks = mergeCommandHooks(hooks);
commandHooksRequest = null;
updateCommandMenu();
}, function() {
if (requestGeneration !== commandMenuGeneration) {
return;
}
commandHooks = mergeCommandHooks([]);
commandHooksRequest = null;
updateCommandMenu();
});
}
return;
}
var prefix = match[1].toLowerCase();
var activeBuffer = models.getActiveBuffer();
var isIrcBuffer = activeBuffer && activeBuffer.plugin === 'irc';
$scope.commandMenuLoading = false;
$scope.commandMenuMatches = commandHooks.filter(function(hook) {
return (!hook.serverCommand || isIrcBuffer) && hook.command.indexOf(prefix) === 0;
});
};
$scope.selectCommand = function(item) {
var suffix = item.args ? ' ' : '';
$scope.command = (item.insertion || ('/' + item.command.toLowerCase())) + suffix;
closeCommandMenu();
setTimeout(function() {
var inputNode = $scope.getInputNode();
inputNode.focus();
inputNode.setSelectionRange($scope.command.length, $scope.command.length);
}, 0);
};
var scrollCommandMenuSelection = function() {
setTimeout(function() {
var selected = document.getElementById(
$scope.inputId + '-command-' + $scope.commandMenuIndex
);
if (selected) {
selected.scrollIntoView({block: 'nearest'});
}
}, 0);
};
$scope.handleCommandMenuKey = function(event, code, inputNode) {
if (suppressCommandMenuKeypress && event.type === 'keypress' &&
(code === 9 || code === 13)) {
event.preventDefault();
return true;
}
if (!$scope.commandMenuOpen || event.type !== 'keydown' ||
document.activeElement !== inputNode || event.altKey ||
event.ctrlKey || event.metaKey) {
return false;
}
var count = $scope.commandMenuMatches.length;
if ((code === 38 || code === 40) && count > 0) {
var direction = code === 38 ? -1 : 1;
$scope.commandMenuIndex = ($scope.commandMenuIndex + direction + count) % count;
scrollCommandMenuSelection();
event.preventDefault();
return true;
}
if ((code === 9 || code === 13) && count > 0 && !event.shiftKey) {
$scope.selectCommand($scope.commandMenuMatches[$scope.commandMenuIndex]);
suppressCommandMenuKeypress = true;
setTimeout(function() { suppressCommandMenuKeypress = false; }, 0);
event.preventDefault();
return true;
}
if (code === 27) {
closeCommandMenu();
event.preventDefault();
return true;
}
return false;
};
var removeCommandMenuDisconnect = $rootScope.$on('relayDisconnect', function() {
commandMenuGeneration++;
commandHooks = null;
commandHooksRequest = null;
closeCommandMenu();
});
$scope.$on('$destroy', removeCommandMenuDisconnect);
+36
View File
@@ -0,0 +1,36 @@
<div class="panel connection-panel" data-state="active">
<div class="panel-collapse collapse">
<div class="panel-body">
<form class="form-signin" role="form">
<div class="relay-terminal">
<div class="relay-terminal-bar"><span></span><span></span><span></span><b>relay@{{RELAY_HOST}}:~</b></div>
<div class="relay-terminal-body">
<p><span class="relay-prompt">&gt;&gt;</span> relay config --show</p>
<dl>
<div><dt>HOSTNAME</dt><dd>{{RELAY_HOST}}</dd></div>
<div><dt>PORT</dt><dd>443</dd></div>
<div><dt>PATH</dt><dd>/weechat</dd></div>
<div><dt>TRANSPORT</dt><dd>WSS / TLS</dd></div>
</dl>
<div class="relay-password-line">
<label for="password"><span class="relay-prompt">&gt;&gt;</span> password</label>
<span class="relay-password-field">
<span class="relay-password-mask" ng-if="password" ng-cloak>{{ maskPassword(password) }}</span>
<span class="relay-password-placeholder" ng-if="!password">[ enter relay password ]</span>
<input type="password" id="password" ng-model="password" autocomplete="current-password" aria-label="WeeChat relay password">
</span>
</div>
<p class="relay-password-error" ng-show="passwordError" ng-cloak><span class="relay-prompt">!!</span> authentication rejected</p>
<p class="relay-input-line"><span class="relay-prompt">&gt;&gt;</span> <span class="relay-cursor" aria-hidden="true">_</span></p>
</div>
</div>
<div class="connection-options">
<label><input type="checkbox" ng-model="settings.tls" disabled> TLS encryption</label>
<label><input type="checkbox" ng-model="settings.savepassword"> Save password in this browser</label>
<label><input type="checkbox" ng-model="settings.autoconnect"> Automatically connect</label>
</div>
<button class="btn btn-lg btn-primary relay-connect" ng-click="connect()" ng-cloak>{{ connectbutton }} <i ng-class="connectbuttonicon" class="glyphicon"></i></button>
</form>
</div>
</div>
</div>
+3
View File
@@ -0,0 +1,3 @@
<div class="neo-context-menu" ng-if="menu.visible" ng-style="menu.position" role="menu" aria-label="{{menu.label}}">
<button type="button" role="menuitem" ng-repeat="item in menu.actions track by item.id" ng-class="{destructive:item.destructive}" ng-click="runContextAction(item)" ng-bind="item.label"></button>
</div>
+95
View File
@@ -0,0 +1,95 @@
'use strict';
(function() {
var weechat = angular.module('weechat');
weechat.factory('neoContextMenu', ['$rootScope', 'connection', 'models', function($rootScope, connection, models) {
var menu = {visible: false, actions: [], position: {}, label: ''};
var send = function(buffer, command) { connection.sendMessageToBuffer(buffer.id, command); };
var action = function(id, label, run, destructive) { return {id: id, label: label, run: run, destructive: Boolean(destructive)}; };
var safeNick = function(value) { return /^[A-Za-z0-9[\]\\`_^{|}-]+$/.test(value || ''); };
function bufferActions(buffer) {
var actions = [];
var core = buffer.fullName === 'core.weechat';
if (buffer.type === 'server' && buffer.plugin === 'irc') {
actions.push(action('edit-server', 'Edit server settings', function() { $rootScope.$broadcast('neoServerManagerOpen', buffer.server || buffer.shortName); }));
actions.push(action('connect', 'Connect', function() { send(buffer, '/connect ' + buffer.server); }));
actions.push(action('disconnect', 'Disconnect', function() { send(buffer, '/disconnect ' + buffer.server); }, true));
}
if (buffer.type === 'channel' && buffer.plugin === 'irc') {
actions.push(action('mute', 'Mute activity', function() { send(buffer, '/buffer notify none'); }));
actions.push(action('unmute', 'Unmute activity', function() { send(buffer, '/buffer notify all'); }));
actions.push(action('part', 'Part channel', function() { send(buffer, '/part'); }, true));
actions.push(action('rejoin', 'Rejoin channel', function() { send(buffer, '/join'); }));
actions.push(action('autojoin-add', 'Add to auto-join', function() { send(buffer, '/autojoin add'); }));
actions.push(action('autojoin-del', 'Remove from auto-join', function() { send(buffer, '/autojoin del'); }));
}
if (/neodrop_manager/.test(buffer.fullName)) {
actions.push(action('drops-list', 'Refresh drops', function() { send(buffer, '/drops list'); }));
actions.push(action('drops-help', 'NeoDrop help', function() { send(buffer, '/drops help'); }));
}
if (/neoguard/.test(buffer.fullName)) {
actions.push(action('guard-status', 'Refresh NeoGuard', function() { send(buffer, '/neoguard status'); }));
actions.push(action('guard-help', 'NeoGuard help', function() { send(buffer, '/neoguard'); }));
}
actions.push(action('clear', 'Clear buffer', function() { send(buffer, '/buffer clear'); }));
if (!core) actions.push(action('close', 'Close buffer', function() {
if (window.confirm('Close ' + (buffer.shortName || buffer.fullName) + '?')) send(buffer, '/buffer close');
}, true));
return actions;
}
function nickActions(target) {
var nick = target.nick.name;
var buffer = target.buffer;
if (!safeNick(nick) || !buffer || buffer.plugin !== 'irc') return [];
var actions = [
action('dm', 'Direct message', function() {
var prefix = buffer.fullName.substring(0, buffer.fullName.lastIndexOf('.') + 1);
if (!models.setActiveBuffer(prefix + nick, 'fullName')) { models.outgoingQueries.push(nick); send(buffer, '/query -noswitch ' + nick); }
}),
action('whois', 'Whois', function() { send(buffer, '/whois ' + nick); })
];
if (buffer.type === 'channel') {
actions.push(action('op', 'Give operator', function() { send(buffer, '/mode +o ' + nick); }));
actions.push(action('halfop', 'Give half-operator', function() { send(buffer, '/mode +h ' + nick); }));
actions.push(action('voice', 'Give voice', function() { send(buffer, '/mode +v ' + nick); }));
actions.push(action('kick', 'Kick', function() { if (window.confirm('Kick ' + nick + '?')) send(buffer, '/kick ' + nick); }, true));
actions.push(action('ban', 'Ban', function() { if (window.confirm('Ban ' + nick + '?')) send(buffer, '/ban ' + nick); }, true));
}
return actions;
}
return {
menu: menu,
open: function(event, target) {
var actions = target.kind === 'nick' ? nickActions(target) : bufferActions(target.buffer);
if (!actions.length) return;
menu.actions = actions;
menu.label = target.kind === 'nick' ? 'Actions for ' + target.nick.name : 'Buffer actions';
menu.position = {left: Math.min(event.clientX, window.innerWidth - 230) + 'px', top: Math.min(event.clientY, window.innerHeight - 360) + 'px'};
menu.visible = true;
},
close: function() { menu.visible = false; },
run: function(item) { menu.visible = false; item.run(); }
};
}]);
weechat.directive('neoContext', ['neoContextMenu', function(neoContextMenu) {
return {restrict: 'A', link: function(scope, element, attrs) {
var open = function(event) { event.preventDefault(); event.stopPropagation(); scope.$apply(function() { neoContextMenu.open(event, scope.$eval(attrs.neoContext)); }); };
element.on('contextmenu', open);
scope.$on('$destroy', function() { element.off('contextmenu', open); });
}};
}]);
weechat.directive('neoContextMenuView', ['neoContextMenu', '$document', function(neoContextMenu, $document) {
return {restrict: 'E', templateUrl: 'directives/context-menu.html', link: function(scope) {
scope.menu = neoContextMenu.menu;
scope.runContextAction = neoContextMenu.run;
var close = function(event) { if (scope.menu.visible && !event.target.closest('.neo-context-menu')) scope.$apply(neoContextMenu.close); };
$document.on('mousedown', close);
scope.$on('$destroy', function() { $document.off('mousedown', close); });
}};
}]);
}());
+910
View File
@@ -0,0 +1,910 @@
@font-face {
font-family: "OpenDyslexic";
src: url("../assets/fonts/OpenDyslexic-Regular.woff2") format("woff2");
font-style: normal;
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "OpenDyslexic";
src: url("../assets/fonts/OpenDyslexic-Italic.woff2") format("woff2");
font-style: italic;
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "OpenDyslexic";
src: url("../assets/fonts/OpenDyslexic-Bold.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
}
@font-face {
font-family: "OpenDyslexic";
src: url("../assets/fonts/OpenDyslexic-BoldItalic.woff2") format("woff2");
font-style: italic;
font-weight: 700;
font-display: swap;
}
:root {
--neorelay-void: #07030d;
--neorelay-ink: #0d0618;
--neorelay-panel: #150a24;
--neorelay-panel-raised: #211034;
--neorelay-violet: #9d5cff;
--neorelay-purple: #6f2cff;
--neorelay-pink: #ff65c8;
--neorelay-cyan: #55e7ff;
--neorelay-acid: #b9ff4f;
--neorelay-text: #f1eaff;
--neorelay-muted: #a99abd;
--neorelay-nick: #55e7ff;
--neorelay-nick-other: #55e7ff;
--neorelay-nick-self: #ff65c8;
--neorelay-nick-op: #ff65c8;
--neorelay-nick-halfop: #55e7ff;
--neorelay-nick-voice: #b9ff4f;
--neorelay-nick-away: #a99abd;
--neorelay-chat-highlight: #ffff55;
--neorelay-chat-highlight-bg: #aa00aa;
--neorelay-chat-time: #f1eaff;
--neorelay-line: rgba(157, 92, 255, 0.35);
--neorelay-sidebar-preferred-width: 140px;
--neorelay-sidebar-width: clamp(120px, var(--neorelay-sidebar-preferred-width), min(480px, 40vw));
--neorelay-page-gutter: 5px;
--neo-preferred-font: "OpenDyslexic", "Atkinson Hyperlegible", system-ui, sans-serif;
--neo-preferred-font-size: 14px;
}
html,
body {
background: var(--neorelay-void) !important;
color: var(--neorelay-text) !important;
font-family: "OpenDyslexic", "Atkinson Hyperlegible", system-ui, sans-serif;
}
body {
background-image:
linear-gradient(rgba(157, 92, 255, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(157, 92, 255, 0.035) 1px, transparent 1px),
radial-gradient(circle at 75% 5%, rgba(111, 44, 255, 0.2), transparent 34%) !important;
background-size: 32px 32px, 32px 32px, auto !important;
}
.favorite-font {
font-family: "OpenDyslexic", "Atkinson Hyperlegible", monospace !important;
}
body.neo-preferred-font {
font-family: var(--neo-preferred-font) !important;
font-size: var(--neo-preferred-font-size) !important;
}
body.neo-preferred-font .content :not(.glyphicon):not(.neo-font-option),
body.neo-preferred-font .gb-modal :not(.glyphicon):not(.neo-font-option),
body.neo-preferred-font .neo-context-menu :not(.glyphicon):not(.neo-font-option),
body.neo-preferred-font .neo-server-manager :not(.glyphicon):not(.neo-font-option) {
font-family: inherit !important;
font-size: inherit !important;
}
a,
.panel-title,
.panel-title a {
color: var(--neorelay-cyan);
}
a:hover,
a:focus {
color: var(--neorelay-acid);
}
/* Logged-out identity */
.login-shell {
position: relative;
width: min(1120px, calc(100% - 40px));
margin: 0 auto;
padding: 54px 0 72px;
}
.login-shell::before {
content: "{{RELAY_GATEWAY_LABEL}}";
display: block;
margin-bottom: 12px;
color: var(--neorelay-muted);
font: 700 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.24em;
}
.login-hero {
display: grid;
grid-template-columns: minmax(250px, 0.78fr) minmax(360px, 1.35fr);
min-height: 390px;
margin-bottom: 28px;
overflow: hidden;
border-top: 1px solid var(--neorelay-violet);
border-bottom: 1px solid var(--neorelay-line);
background:
linear-gradient(115deg, rgba(22, 9, 40, 0.96), rgba(9, 3, 17, 0.78)),
linear-gradient(90deg, transparent 49.8%, rgba(85, 231, 255, 0.12) 50%, transparent 50.2%);
box-shadow: 20px 20px 0 rgba(72, 24, 122, 0.12);
}
.login-portrait {
position: relative;
min-height: 390px;
overflow: hidden;
border-right: 1px solid var(--neorelay-line);
}
.login-portrait::after {
position: absolute;
inset: 0;
content: "";
pointer-events: none;
background:
repeating-linear-gradient(0deg, transparent 0 4px, rgba(7, 3, 13, 0.16) 5px),
linear-gradient(180deg, transparent 55%, rgba(7, 3, 13, 0.8));
}
.login-portrait img {
width: 100%;
height: 100%;
min-height: 390px;
object-fit: cover;
object-position: 50% 27%;
filter: saturate(1.12) contrast(1.04);
}
.portrait-code {
position: absolute;
z-index: 1;
right: 14px;
bottom: 12px;
color: var(--neorelay-acid);
font: 700 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.18em;
}
.login-intro {
position: relative;
align-self: center;
padding: 48px clamp(32px, 7vw, 86px);
}
.login-intro::before {
position: absolute;
top: 28px;
left: 0;
width: 46px;
height: 3px;
content: "";
background: var(--neorelay-pink);
box-shadow: 19px 8px 0 var(--neorelay-cyan);
}
.login-kicker {
margin: 0 0 20px;
color: var(--neorelay-acid);
font: 700 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.2em;
}
.login-intro h1 {
margin: 0;
color: var(--neorelay-text);
font-family: "Arial Black", Impact, sans-serif;
font-size: clamp(48px, 8vw, 92px);
font-weight: 900;
line-height: 0.78;
letter-spacing: -0.08em;
text-transform: uppercase;
text-shadow: 4px 4px 0 rgba(111, 44, 255, 0.65);
}
.login-intro h1 span {
display: block;
margin-left: 0.47em;
color: transparent;
-webkit-text-stroke: 1px var(--neorelay-pink);
text-shadow: none;
}
.login-deck {
max-width: 520px;
margin: 30px 0 25px;
color: #cfc3dd;
font-size: 15px;
line-height: 1.75;
}
.login-status {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.login-status span {
padding: 7px 9px 6px;
border: 1px solid rgba(85, 231, 255, 0.35);
color: var(--neorelay-cyan);
background: rgba(85, 231, 255, 0.04);
font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.12em;
}
.login-shell .panel-group {
display: grid;
grid-template-columns: 1.15fr 0.85fr;
gap: 1px;
padding: 1px;
background: var(--neorelay-line);
}
.login-shell .panel {
margin: 0 !important;
border: 0;
border-radius: 0;
color: var(--neorelay-text);
background: rgba(20, 9, 35, 0.97) !important;
box-shadow: none;
}
.login-shell .panel:first-of-type,
.login-shell .panel:nth-of-type(2) {
grid-column: 1 / -1;
}
.login-shell .panel-heading {
padding: 17px 20px;
border: 0;
background: linear-gradient(90deg, rgba(111, 44, 255, 0.18), transparent);
}
.login-shell .panel-title {
color: var(--neorelay-text);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.login-shell .panel[data-state=active] .panel-title::before {
color: var(--neorelay-acid);
}
.login-shell .panel[data-state=collapsed] .panel-title::before {
color: var(--neorelay-pink);
}
.login-shell .panel-body {
padding: 24px 20px;
border-color: var(--neorelay-line) !important;
color: #c9bdd8;
}
.connection-panel .panel-body {
padding: clamp(18px, 4vw, 38px);
}
.relay-terminal {
border: 1px solid rgba(157, 92, 255, 0.5);
background: rgba(5, 2, 10, 0.94);
box-shadow: 12px 12px 0 rgba(111, 44, 255, 0.12);
font: 700 clamp(12px, 1.8vw, 16px)/1.75 ui-monospace, SFMono-Regular, Menlo, monospace;
}
.relay-terminal-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--neorelay-line);
color: var(--neorelay-muted);
font-size: 10px;
letter-spacing: 0.08em;
}
.relay-terminal-bar span {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--neorelay-pink);
}
.relay-terminal-bar span:nth-child(2) { background: var(--neorelay-violet); }
.relay-terminal-bar span:nth-child(3) { background: var(--neorelay-acid); }
.relay-terminal-bar b { margin-left: 6px; font-weight: 700; }
.relay-terminal-body { padding: clamp(20px, 4vw, 34px); }
.relay-terminal-body p { margin: 0 0 8px; color: #c8c2cf; }
.relay-prompt { color: var(--neorelay-violet); }
.relay-terminal dl {
margin: 22px 0;
border-top: 1px solid var(--neorelay-line);
}
.relay-terminal dl div {
display: grid;
grid-template-columns: minmax(100px, 0.35fr) 1fr;
gap: 18px;
padding: 10px 0;
border-bottom: 1px solid rgba(157, 92, 255, 0.16);
}
.relay-terminal dt {
color: var(--neorelay-muted);
font-size: 9px;
letter-spacing: 0.14em;
}
.relay-terminal dd {
margin: 0;
color: var(--neorelay-cyan);
font-size: 11px;
}
.relay-password-line {
display: flex;
align-items: center;
gap: 14px;
margin-top: 22px;
}
.relay-password-line label {
flex: 0 0 auto;
margin: 0;
color: #c8c2cf;
font: inherit;
cursor: text;
}
.relay-password-field {
position: relative;
display: block;
flex: 1 1 auto;
min-width: 180px;
min-height: 31px;
padding: 2px 10px;
overflow: hidden;
border-bottom: 1px solid rgba(85, 231, 255, 0.34);
color: var(--neorelay-acid);
white-space: nowrap;
}
.relay-password-field:focus-within {
border-color: var(--neorelay-cyan);
box-shadow: 0 5px 14px rgba(85, 231, 255, 0.08);
}
.relay-password-placeholder { color: #71687d; font-weight: 400; }
.relay-password-mask { letter-spacing: 0.14em; }
body .relay-password-field input[type="password"] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: 0 !important;
opacity: 0;
cursor: text;
box-shadow: none !important;
}
.relay-password-error { margin-top: 10px !important; color: var(--neorelay-pink) !important; }
.relay-input-line { margin-top: 18px !important; }
.relay-cursor { color: var(--neorelay-acid); animation: relay-blink 1s steps(1, end) infinite; }
.connection-options {
display: flex;
flex-wrap: wrap;
gap: 10px 22px;
margin: 30px 0 22px;
color: #c9bdd8;
font: 700 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
}
.connection-options label { margin: 0; cursor: pointer; }
.connection-options input { margin: 0 7px 0 0; accent-color: var(--neorelay-pink); }
.connection-options input:disabled { opacity: 1; cursor: default; }
.relay-connect { min-width: 180px; }
.login-shell .form-control,
body .form-control,
body input[type="text"],
body input[type="password"],
body #sendMessage {
border: 1px solid rgba(157, 92, 255, 0.38) !important;
border-radius: 0 !important;
color: var(--neorelay-text) !important;
background: rgba(4, 1, 9, 0.65) !important;
box-shadow: inset 3px 0 0 rgba(157, 92, 255, 0.25) !important;
}
body .form-control:focus,
body input:focus,
body #sendMessage:focus {
border-color: var(--neorelay-cyan) !important;
box-shadow: inset 3px 0 0 var(--neorelay-cyan), 0 0 18px rgba(85, 231, 255, 0.12) !important;
}
@keyframes relay-blink {
50% { opacity: 0; }
}
.btn-primary,
.login-shell .btn-primary {
border: 0;
border-radius: 0;
color: #09020f;
background: linear-gradient(90deg, var(--neorelay-pink), var(--neorelay-violet));
box-shadow: 6px 6px 0 rgba(85, 231, 255, 0.16);
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.btn-primary:hover,
.btn-primary:focus {
color: #09020f;
background: linear-gradient(90deg, var(--neorelay-acid), var(--neorelay-cyan));
}
.alert {
border: 1px solid currentColor;
border-radius: 0;
background: rgba(10, 4, 18, 0.95);
}
.alert-danger {
color: #ff8bcf !important;
border-color: rgba(255, 101, 200, 0.55);
background: rgba(83, 13, 60, 0.36) !important;
}
/* Connected client */
body #topbar {
border-bottom: 1px solid var(--neorelay-violet);
background: linear-gradient(90deg, #1b0a30, #0a0411 65%) !important;
box-shadow: 0 8px 24px rgba(3, 0, 8, 0.45);
}
body #topbar .brand img {
border-radius: 50%;
filter: saturate(1.15);
}
body #topbar .actions {
color: var(--neorelay-muted);
background: rgba(23, 9, 38, 0.9) !important;
}
body #sidebar,
body #nicklist {
border-color: var(--neorelay-line);
background: rgba(12, 5, 21, 0.98) !important;
}
body #sidebar { display:flex; flex-direction:column; overflow:hidden; }
body #sidebar > ul.nav { flex:1 1 auto; min-height:0; margin-bottom:0; overflow-y:auto; }
body #nicklist { right:var(--neorelay-page-gutter); box-sizing:border-box; }
body #bufferlines.withnicklist { margin-right:105px !important; }
body .footer.withnicklist { padding-right:105px; }
body #bufferlines > table { width:calc(100% - 10px); margin-right:var(--neorelay-page-gutter); margin-left:var(--neorelay-page-gutter); }
body .footer > [input-bar] { display:block; margin-right:var(--neorelay-page-gutter); margin-left:var(--neorelay-page-gutter); }
#sidebar-resizer {
display: none;
}
@media (min-width: 969px) {
body #sidebar {
width: var(--neorelay-sidebar-width);
}
body #topbar .title {
left: calc(var(--neorelay-sidebar-width) + 5px);
}
body .content[sidebar-state="visible"] #bufferlines {
margin-left: calc(var(--neorelay-sidebar-width) + 5px);
}
body .content[sidebar-state="visible"] .footer {
padding-left: calc(var(--neorelay-sidebar-width) + 5px);
}
body .content[sidebar-state="visible"] #sidebar-resizer {
position: fixed;
z-index: 3;
top: 35px;
bottom: 0;
left: calc(var(--neorelay-sidebar-width) - 4px);
display: block;
width: 9px;
cursor: col-resize;
touch-action: none;
}
body #sidebar .nav-pills > li.buffer > a {
padding-top: 2.5px;
padding-bottom: 2.5px;
}
body.neorelay-sidebar-resizing,
body.neorelay-sidebar-resizing * {
cursor: col-resize !important;
user-select: none !important;
}
body.neorelay-sidebar-resizing #sidebar,
body.neorelay-sidebar-resizing #bufferlines,
body.neorelay-sidebar-resizing .footer {
transition: none !important;
}
}
@media (min-width: 1400px) {
:root {
--neorelay-sidebar-preferred-width: 200px;
}
body #sidebar .nav-pills > li.buffer > a {
padding-top: 5px;
padding-bottom: 5px;
}
body #bufferlines.withnicklist { margin-right:145px !important; }
body .footer.withnicklist { padding-right:153px !important; }
}
body .nav-pills li:nth-child(2n) {
background: rgba(157, 92, 255, 0.035);
}
body .nav-pills > li > a {
color: #cbbdde;
}
body .nav-pills > li.active > a,
body .nav-pills > li.active > a:hover {
color: #fff;
background: linear-gradient(90deg, rgba(111, 44, 255, 0.65), rgba(255, 101, 200, 0.15)) !important;
box-shadow: inset 3px 0 0 var(--neorelay-pink);
}
body .nav-pills > li.highlight > a,
body .nav-pills > li.highlight > a span {
color: #08030d;
background: var(--neorelay-acid) !important;
}
.sidebar-actions { position:static; flex:0 0 auto; display:grid; grid-template-columns:repeat(auto-fit,minmax(48px,1fr)); gap:1px; margin-top:0; border-top:1px solid var(--neorelay-line); background:var(--neorelay-void); }
.sidebar-actions button { min-width:0; padding:9px 3px; border:0; color:var(--neorelay-muted); background:rgba(21,10,36,.96); font:700 9px/1.2 ui-monospace,monospace; }
.sidebar-actions button:hover { color:var(--neorelay-cyan); background:rgba(111,44,255,.28); }
.sidebar-actions i,.sidebar-actions span { display:block; margin:auto; }
.sidebar-actions span { margin-top:4px; }
.neo-context-menu { position:fixed; z-index:1200; display:grid; width:220px; overflow:hidden; border:1px solid var(--neorelay-violet); background:rgba(7,3,13,.96); box-shadow:10px 10px 24px rgba(0,0,0,.5); }
.neo-context-menu button { padding:9px 12px; border:0; border-bottom:1px solid rgba(157,92,255,.15); color:var(--neorelay-text); text-align:left; background:transparent; }
.neo-context-menu button:hover,.neo-context-menu button:focus { color:var(--neorelay-cyan); background:rgba(111,44,255,.35); outline:0; }
.neo-context-menu button.destructive { color:var(--neorelay-pink); }
.neo-server-manager .modal-dialog { width:min(1100px,calc(100% - 24px)); }
.neo-server-layout { display:grid; grid-template-columns:190px 1fr; gap:18px; max-height:72vh; overflow:auto; }
.neo-server-layout aside { display:flex; flex-direction:column; gap:4px; }
.neo-server-layout aside button { padding:8px; border:1px solid var(--neorelay-line); color:var(--neorelay-text); text-align:left; background:rgba(7,3,13,.7); }
.neo-server-layout aside .neo-add-server { box-sizing:border-box; width:100%; min-height:38px; padding:8px 10px; border:1px solid var(--neorelay-cyan); color:var(--neorelay-void); text-align:center; white-space:normal; background:linear-gradient(90deg,var(--neorelay-pink),var(--neorelay-violet)); box-shadow:none; font-size:10px; line-height:20px; }
.neo-server-layout aside .neo-add-server:hover,.neo-server-layout aside .neo-add-server:focus { color:var(--neorelay-void); background:linear-gradient(90deg,var(--neorelay-acid),var(--neorelay-cyan)); }
.neo-server-options { display:grid; grid-template-columns:repeat(2,minmax(240px,1fr)); gap:8px 14px; margin-top:14px; }
.neo-server-options label { display:grid; grid-template-columns:minmax(130px,.8fr) minmax(120px,1.2fr) auto; align-items:center; gap:8px; margin:0; color:var(--neorelay-muted); font:11px/1.3 ui-monospace,monospace; }
.neo-server-manager,.neo-server-manager button,.neo-server-manager input,.neo-server-manager select { font-family:"OpenDyslexic","Atkinson Hyperlegible",system-ui,sans-serif; }
.neo-server-manager .neo-server-control { box-sizing:border-box; width:100%; min-width:0; height:34px; min-height:34px; margin:0 !important; padding:6px 10px; border:1px solid var(--neorelay-line) !important; color:var(--neorelay-text); background:var(--neorelay-void); font-size:12px; line-height:20px; }
.neo-server-manager .modal-footer .btn { min-width:92px; height:34px; padding:7px 13px; font-size:11px; line-height:20px; }
.neo-server-manager .modal-footer .btn-default { border:1px solid var(--neorelay-line); color:var(--neorelay-text); background:var(--neorelay-panel-raised); }
.neo-server-manager .modal-footer .btn-default:hover,.neo-server-manager .modal-footer .btn-default:focus { color:var(--neorelay-cyan); background:rgba(111,44,255,.28); }
.neo-server-options .inherit { white-space:nowrap; }
#settingsModal .modal-dialog { width:min(900px,calc(100% - 24px)); }
.neo-font-picker { position:relative; }
.neo-font-trigger { display:flex; align-items:center; justify-content:space-between; width:100%; overflow:hidden; text-align:left; }
.neo-font-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.neo-font-menu { position:absolute; z-index:1300; top:calc(100% + 4px); left:0; display:grid; width:max(100%,320px); padding:4px; border:1px solid var(--neorelay-violet); background:var(--neorelay-void); box-shadow:10px 10px 24px rgba(0,0,0,.55); }
.neo-font-option { display:flex; align-items:center; justify-content:space-between; gap:14px; padding:9px 11px; border:0; border-bottom:1px solid rgba(157,92,255,.18); color:var(--neorelay-text); background:transparent; font-size:16px !important; text-align:left; }
.neo-font-option:last-child { border-bottom:0; }
.neo-font-option:hover,.neo-font-option:focus { color:var(--neorelay-void); background:linear-gradient(90deg,var(--neorelay-cyan),var(--neorelay-acid)); outline:0; }
.neo-font-option span { font-size:.88em !important; }
.neo-font-preview { margin:8px 0 0; padding:9px 11px; border:1px solid var(--neorelay-line); color:var(--neorelay-text); background:rgba(7,3,13,.55); }
.neo-settings-tabs { display:flex; gap:1px; margin:-15px -15px 18px; padding:1px; background:var(--neorelay-line); }
.neo-settings-tabs button { flex:1 1 0; padding:11px 16px; border:0; color:var(--neorelay-muted); background:var(--neorelay-ink); font:800 11px/1 ui-monospace,monospace; letter-spacing:.08em; text-transform:uppercase; }
.neo-settings-tabs button.active { color:var(--neorelay-void); background:linear-gradient(90deg,var(--neorelay-cyan),var(--neorelay-acid)); }
.neo-settings-panel { margin:0; }
.neo-colour-settings h5,.neo-weechat-settings h5 { margin:20px 0 9px; color:var(--neorelay-cyan); font:800 11px/1 ui-monospace,monospace; letter-spacing:.1em; text-transform:uppercase; }
.neo-colour-settings p,.neo-weechat-settings p { color:var(--neorelay-muted); }
.neo-settings-toolbar { display:flex; align-items:end; gap:10px; margin-bottom:12px; }
.neo-settings-toolbar p { flex:1 1 auto; margin:0; }
.neo-settings-toolbar label { flex:1 1 220px; margin:0; color:var(--neorelay-muted); font-size:11px; }
.neo-settings-toolbar .form-control { height:34px; margin:4px 0 0 !important; }
.neo-colour-grid { display:grid; grid-template-columns:repeat(2,minmax(250px,1fr)); gap:8px; }
.neo-colour-grid label { display:grid; grid-template-columns:minmax(110px,1fr) minmax(120px,.8fr) 28px; align-items:center; gap:8px 10px; margin:0; padding:8px 10px; border:1px solid rgba(157,92,255,.2); background:rgba(7,3,13,.45); color:var(--neorelay-text); font-weight:600; }
.neo-colour-grid .form-control { height:34px; margin:0 !important; }
.neo-colour-grid code { grid-column:1 / -1; overflow:hidden; padding:0; color:var(--neorelay-muted); background:transparent; font-size:9px; text-overflow:ellipsis; white-space:nowrap; }
.neo-colour-swatch { display:block; width:26px; height:26px; border:1px solid rgba(255,255,255,.45); box-shadow:inset 0 0 0 1px rgba(0,0,0,.35); }
.neo-option-list { display:grid; gap:7px; max-height:55vh; overflow:auto; }
.neo-option { display:grid; grid-template-columns:minmax(260px,1fr) minmax(160px,.65fr) auto; align-items:center; gap:10px; padding:10px; border:1px solid rgba(157,92,255,.2); background:rgba(7,3,13,.45); }
.neo-option strong,.neo-option small { display:block; overflow-wrap:anywhere; }
.neo-option strong { color:var(--neorelay-cyan); font:700 10px/1.3 ui-monospace,monospace; }
.neo-option small { margin-top:3px; color:var(--neorelay-muted); font-size:9px; }
.neo-option .form-control { height:34px; margin:0 !important; }
.neo-option-actions { display:flex; gap:5px; }
.neo-option-actions .btn { min-width:64px; height:34px; }
.neo-palette-setting { display:grid; grid-template-columns:1fr auto; gap:8px; padding:10px; border:1px solid rgba(157,92,255,.2); background:rgba(7,3,13,.45); }
.neo-palette-setting .form-control { height:34px; margin:0 !important; }
.neo-palette-setting code { grid-column:1 / -1; color:var(--neorelay-muted); background:transparent; font-size:9px; }
.neo-palette-preview { grid-column:1 / -1; display:flex; align-items:center; gap:3px; min-height:20px; overflow-x:auto; }
.neo-palette-preview i { flex:0 0 20px; width:20px; height:20px; border:1px solid rgba(255,255,255,.4); box-shadow:inset 0 0 0 1px rgba(0,0,0,.3); }
body #nicklist a { cursor:text; }
body .neo-nick { color:var(--neorelay-nick) !important; }
body .neo-nick-op { color:var(--neorelay-nick-op) !important; }
body .neo-nick-halfop { color:var(--neorelay-nick-halfop) !important; }
body .neo-nick-voice { color:var(--neorelay-nick-voice) !important; }
body .neo-nick-away { color:var(--neorelay-nick-away) !important; font-style:italic; opacity:.85; }
body #nicklist .cof-chat_nick_self { color:var(--neorelay-nick-self) !important; font-style:normal; font-weight:900; opacity:1; text-shadow:1px 1px 0 var(--neorelay-purple),0 0 8px rgba(157,92,255,.45); }
body tr.bufferline:hover {
background: rgba(157, 92, 255, 0.08) !important;
}
body #bufferlines tr.bufferline:has(td.prefix .highlight) {
background: var(--neorelay-chat-highlight-bg) !important;
box-shadow: inset 3px 0 0 var(--neorelay-pink);
}
body #bufferlines td.prefix .highlight {
color: var(--neorelay-chat-highlight) !important;
font-weight: 900;
text-shadow: 0 0 8px rgba(255, 101, 200, 0.48);
}
body td.prefix {
border-color: rgba(157, 92, 255, 0.2);
}
body .footer {
bottom: 5px;
padding-bottom: 0;
border-top: 1px solid rgba(157, 92, 255, 0.35);
background: var(--neorelay-ink);
}
body #bufferlines { bottom:40px; }
.command-menu {
position: absolute;
z-index: 5;
right: 0;
bottom: calc(100% + 4px);
left: 0;
max-height: min(40dvh, 20rem);
overflow-y: auto;
border: 1px solid var(--neorelay-line);
background: rgba(7, 3, 13, 0.90);
box-shadow: 8px -8px 24px rgba(3, 0, 8, 0.45);
overscroll-behavior: contain;
}
.command-menu-item {
display: grid;
grid-template-columns: minmax(9rem, 0.3fr) minmax(12rem, 0.7fr) minmax(14rem, 1fr);
gap: 12px;
width: 100%;
padding: 7px 10px;
border: 0;
border-bottom: 1px solid rgba(157, 92, 255, 0.14);
color: var(--neorelay-text);
text-align: left;
background: transparent;
}
.command-menu-item:hover,
.command-menu-item.selected {
background: linear-gradient(90deg, rgba(111, 44, 255, 0.55), rgba(255, 101, 200, 0.08));
box-shadow: inset 3px 0 0 var(--neorelay-cyan);
}
.command-menu-name {
color: var(--neorelay-cyan);
font-weight: 800;
}
.command-menu-args {
color: #8f8699;
white-space: pre-wrap;
}
.command-menu-description {
overflow: hidden;
color: var(--neorelay-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.command-menu-empty {
padding: 10px;
color: var(--neorelay-muted);
}
body .modal-content,
body .dropdown-menu {
border: 1px solid var(--neorelay-line);
border-radius: 0;
color: var(--neorelay-text);
background: var(--neorelay-panel) !important;
box-shadow: 12px 12px 0 rgba(111, 44, 255, 0.15);
}
body .badge.danger,
body .danger {
color: #09020f;
background: var(--neorelay-pink) !important;
}
body #readmarker {
border-top-color: var(--neorelay-pink);
border-bottom-color: var(--neorelay-cyan);
}
.neorelay-core-title {
color: var(--neorelay-text);
font: 800 12px/35px "OpenDyslexic", ui-monospace, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.neorelay-core-title small {
margin-left: 8px;
color: var(--neorelay-acid);
font: 700 9px/35px ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.14em;
}
.core-login-hero {
width: min(960px, calc(100% - 32px));
margin: 6vh auto 60px;
}
body .cof-chat_nick,
body .cof-chat_host,
body .cof-chat_value {
color: var(--neorelay-nick);
}
body .cof-chat_nick_other { color:var(--neorelay-nick-other); }
body .cof-chat_prefix_join,
body .cof-chat_nick_prefix {
color: var(--neorelay-acid);
}
body .cof-chat_nick_self {
color: var(--neorelay-nick-self);
font-style: normal;
font-weight: 900;
opacity: 1;
text-shadow: 1px 1px 0 var(--neorelay-purple), 0 0 8px rgba(157, 92, 255, 0.45);
}
body .cof-chat_time { color:var(--neorelay-chat-time); }
body .cof-chat_prefix_network,
body .cof-chat_prefix_more,
body .cof-chat_read_marker {
color: var(--neorelay-pink);
}
::-webkit-scrollbar-track-piece {
background: var(--neorelay-void) !important;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(var(--neorelay-purple), var(--neorelay-pink)) !important;
}
@media (max-width: 760px) {
.login-shell {
width: min(100% - 24px, 560px);
padding-top: 28px;
}
.neo-server-layout { grid-template-columns:1fr; }
.neo-server-options { grid-template-columns:1fr; }
.neo-colour-grid { grid-template-columns:1fr; }
.neo-settings-toolbar { align-items:stretch; flex-direction:column; }
.neo-settings-toolbar label { width:100%; flex-basis:auto; }
.neo-option { grid-template-columns:1fr; }
.neo-option-actions .btn { flex:1 1 auto; }
.login-hero {
grid-template-columns: 1fr;
box-shadow: 10px 10px 0 rgba(72, 24, 122, 0.12);
}
.login-portrait,
.login-portrait img {
min-height: 300px;
max-height: 380px;
}
.login-portrait {
border-right: 0;
border-bottom: 1px solid var(--neorelay-line);
}
.login-intro {
padding: 42px 24px 36px;
}
.login-intro h1 {
font-size: clamp(48px, 18vw, 76px);
}
.login-shell .panel-group {
display: block;
}
.login-shell .panel + .panel {
border-top: 1px solid var(--neorelay-line);
}
.connection-panel .panel-body { padding: 14px; }
.relay-terminal { box-shadow: 8px 8px 0 rgba(111, 44, 255, 0.1); }
.relay-password-line { display: block; }
.relay-password-field { margin-top: 5px; }
.connection-options { display: grid; }
.relay-connect { width: 100%; }
.command-menu {
max-height: min(35dvh, 16rem);
}
.command-menu-item {
grid-template-columns: minmax(8rem, 0.45fr) 1fr;
min-height: 44px;
touch-action: manipulation;
}
.command-menu-description {
display: none;
}
.neorelay-core-title small {
display: none;
}
.core-login-hero {
margin: 32px 12px 60px;
width: calc(100% - 24px);
}
}
@media (max-width: 968px) {
body .footer.withnicklist { padding-right:113px !important; }
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+13
View File
@@ -0,0 +1,13 @@
<li class="standard-labels">
<form class="form-horizontal" role="form">
<div class="form-group">
<label for="drop-profile" class="col-sm-3 control-label make-thinner">NeoDrop retention</label>
<div class="col-sm-8">
<select id="drop-profile" class="form-control" ng-model="settings.dropProfile">
<option value="small">50 MiB maximum / 30 days</option>
<option value="large">1 GiB maximum / 7 days</option>
</select>
</div>
</div>
</form>
</li>
+94
View File
@@ -0,0 +1,94 @@
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
with Reserved Font Name OpenDyslexic.
Copyright (c) 12/2012 - 2019
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
<section class="login-hero" aria-labelledby="neorelay-login-title">
<div class="login-portrait">
<img src="assets/neorelay-avatar-login.jpg" alt="Operator-provided avatar">
<span class="portrait-code" aria-hidden="true">{{NODE_LABEL}}</span>
</div>
<div class="login-intro">
<p class="login-kicker">PRIVATE RELAY // TLS 1.3</p>
<h1 id="neorelay-login-title">{{RELAY_TITLE_HTML}}</h1>
<p class="login-deck">A quiet line into IRC.</p>
<div class="login-status" aria-label="Service capabilities">
<span>INTERNAL ONLY</span>
<span>SASL EXTERNAL</span>
<span>ALWAYS ON</span>
</div>
</div>
</section>
+326
View File
@@ -0,0 +1,326 @@
(function(root, factory) {
'use strict';
var api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.NeoDropCrypto = api;
}(typeof self !== 'undefined' ? self : this, function() {
'use strict';
var CHUNK_SIZE = 4 * 1024 * 1024;
var SMALL_MAX = 50 * 1024 * 1024;
var LARGE_MAX = 1024 * 1024 * 1024;
var encoder = new TextEncoder();
var SHA256_K = new Uint32Array([
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
]);
function rotate(value, bits) {
return (value >>> bits) | (value << (32 - bits));
}
function Sha256() {
this.state = new Uint32Array([
0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,
0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19
]);
this.buffer = new Uint8Array(64);
this.bufferLength = 0;
this.bytes = 0;
}
Sha256.prototype.transform = function(bytes, offset) {
var words = new Uint32Array(64);
var view = new DataView(bytes.buffer, bytes.byteOffset + offset, 64);
var i;
for (i = 0; i < 16; i++) words[i] = view.getUint32(i * 4, false);
for (i = 16; i < 64; i++) {
var x = words[i - 15];
var y = words[i - 2];
var s0 = rotate(x, 7) ^ rotate(x, 18) ^ (x >>> 3);
var s1 = rotate(y, 17) ^ rotate(y, 19) ^ (y >>> 10);
words[i] = (words[i - 16] + s0 + words[i - 7] + s1) >>> 0;
}
var a = this.state[0], b = this.state[1], c = this.state[2], d = this.state[3];
var e = this.state[4], f = this.state[5], g = this.state[6], h = this.state[7];
for (i = 0; i < 64; i++) {
var sum1 = rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25);
var choice = (e & f) ^ (~e & g);
var temporary1 = (h + sum1 + choice + SHA256_K[i] + words[i]) >>> 0;
var sum0 = rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22);
var majority = (a & b) ^ (a & c) ^ (b & c);
var temporary2 = (sum0 + majority) >>> 0;
h = g; g = f; f = e; e = (d + temporary1) >>> 0;
d = c; c = b; b = a; a = (temporary1 + temporary2) >>> 0;
}
this.state[0] = (this.state[0] + a) >>> 0;
this.state[1] = (this.state[1] + b) >>> 0;
this.state[2] = (this.state[2] + c) >>> 0;
this.state[3] = (this.state[3] + d) >>> 0;
this.state[4] = (this.state[4] + e) >>> 0;
this.state[5] = (this.state[5] + f) >>> 0;
this.state[6] = (this.state[6] + g) >>> 0;
this.state[7] = (this.state[7] + h) >>> 0;
};
Sha256.prototype.update = function(bytes) {
this.bytes += bytes.length;
var offset = 0;
if (this.bufferLength) {
var copied = Math.min(64 - this.bufferLength, bytes.length);
this.buffer.set(bytes.subarray(0, copied), this.bufferLength);
this.bufferLength += copied;
offset = copied;
if (this.bufferLength === 64) {
this.transform(this.buffer, 0);
this.bufferLength = 0;
}
}
while (offset + 64 <= bytes.length) {
this.transform(bytes, offset);
offset += 64;
}
if (offset < bytes.length) {
this.buffer.set(bytes.subarray(offset), 0);
this.bufferLength = bytes.length - offset;
}
};
Sha256.prototype.hex = function() {
var high = Math.floor(this.bytes / 0x20000000);
var low = (this.bytes * 8) >>> 0;
this.buffer[this.bufferLength++] = 0x80;
if (this.bufferLength > 56) {
this.buffer.fill(0, this.bufferLength);
this.transform(this.buffer, 0);
this.bufferLength = 0;
}
this.buffer.fill(0, this.bufferLength, 56);
var view = new DataView(this.buffer.buffer);
view.setUint32(56, high, false);
view.setUint32(60, low, false);
this.transform(this.buffer, 0);
var result = '';
for (var i = 0; i < this.state.length; i++) {
result += this.state[i].toString(16).padStart(8, '0');
}
return result;
};
function b64url(bytes) {
var binary = '';
for (var i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function derive(secret, info, length) {
return crypto.subtle.importKey('raw', secret, 'HKDF', false, ['deriveBits']).then(function(key) {
return crypto.subtle.deriveBits({
name: 'HKDF', hash: 'SHA-256',
salt: encoder.encode('{{NEODROP_CRYPTO_CONTEXT}}'),
info: encoder.encode(info)
}, key, length * 8);
}).then(function(bits) { return new Uint8Array(bits); });
}
function importAes(raw) {
return crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt']);
}
function setU32(bytes, offset, value) {
new DataView(bytes.buffer).setUint32(offset, value, false);
}
function setU64(bytes, offset, value) {
new DataView(bytes.buffer).setBigUint64(offset, BigInt(value), false);
}
function makeAad(type, profile, objectId, size, count, index, length) {
var bytes = new Uint8Array(60);
bytes.set(encoder.encode('NEODRP01'));
bytes[8] = type;
bytes[9] = profile;
bytes.set(objectId, 12);
setU64(bytes, 36, size);
setU32(bytes, 44, CHUNK_SIZE);
setU32(bytes, 48, count);
setU32(bytes, 52, index);
setU32(bytes, 56, length);
return bytes;
}
function makeIv(type, index) {
var bytes = new Uint8Array(12);
setU32(bytes, 0, type);
setU64(bytes, 4, index);
return bytes;
}
function encrypt(key, plaintext, nonce, additionalData) {
return crypto.subtle.encrypt({
name: 'AES-GCM', iv: nonce, additionalData: additionalData, tagLength: 128
}, key, plaintext);
}
function typeFor(type, name) {
var value = String(type || '').toLowerCase();
if (value && value !== 'application/octet-stream') return value;
var match = String(name || '').toLowerCase().match(/\.([a-z0-9]+)$/);
var types = {
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
webp: 'image/webp', avif: 'image/avif', flac: 'audio/flac', m4a: 'audio/mp4',
mp3: 'audio/mpeg', oga: 'audio/ogg', ogg: 'audio/ogg', opus: 'audio/opus',
wav: 'audio/wav', wave: 'audio/wav', m4v: 'video/mp4', mkv: 'video/x-matroska',
mov: 'video/quicktime', mp4: 'video/mp4', ogv: 'video/ogg', webm: 'video/webm'
};
return match && types[match[1]] ? types[match[1]] : 'application/octet-stream';
}
function kindFor(type) {
if (/^image\/(png|jpeg|gif|webp|avif)$/i.test(type)) return 'image';
if (/^audio\//i.test(type)) return 'audio';
if (/^video\//i.test(type)) return 'video';
if (/^text\//i.test(type) || /^(application\/(json|javascript|x-.*script))$/i.test(type)) return 'text';
return 'binary';
}
function safeName(name) {
var value = String(name || 'drop.bin').split(/[\\/]/).pop()
.replace(/[\u0000-\u001f\u007f]/g, '').replace(/^\.+|\.+$/g, '').slice(0, 240);
return value || 'drop.bin';
}
function request(base, path, options, jsonResponse) {
return fetch(base + path, options).then(function(response) {
if (!response.ok) throw new Error('NeoDrop request failed');
return jsonResponse ? response.json() : response;
}).catch(function(error) {
if (error && error.name === 'AbortError') throw error;
throw new Error('NeoDrop request failed');
});
}
async function upload(file, options) {
options = options || {};
if (!file || typeof file.size !== 'number') throw new Error('Select a file.');
var profile = Number(options.profile);
if (profile !== 0 && profile !== 1) throw new Error('Invalid retention profile.');
var maximum = profile === 0 ? SMALL_MAX : LARGE_MAX;
if (file.size > maximum) throw new Error('File exceeds the selected profile limit.');
var base = String(options.base || '');
var progress = typeof options.onProgress === 'function' ? options.onProgress : function() {};
var signal = options.signal;
var secret = crypto.getRandomValues(new Uint8Array(32));
var objectId;
var objectIdText;
var uploadSession;
var chunks = file.size ? Math.ceil(file.size / CHUNK_SIZE) : 0;
var totalSteps = chunks + 3;
var completed = 0;
try {
var keys = await Promise.all([
derive(secret, 'object-id', 24),
derive(secret, 'content-key', 32).then(importAes),
derive(secret, 'metadata-key', 32).then(importAes)
]);
objectId = keys[0];
objectIdText = b64url(objectId);
uploadSession = await request(base, '/api/v1/uploads', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
v: 1, id: objectIdText, profile: profile, size: file.size,
chunkSize: CHUNK_SIZE, chunks: chunks
}),
signal: signal
}, true);
if (!uploadSession || !/^[A-Za-z0-9_-]{22}$/.test(uploadSession.uploadId || '') ||
!/^[A-Za-z0-9_-]{43}$/.test(uploadSession.uploadToken || '')) {
throw new Error('NeoDrop request failed');
}
progress(++completed / totalSteps);
var authorization = 'Bearer ' + uploadSession.uploadToken;
var hasher = new Sha256();
for (var index = 0; index < chunks; index++) {
var plaintext = new Uint8Array(await file.slice(
index * CHUNK_SIZE, Math.min(file.size, (index + 1) * CHUNK_SIZE)
).arrayBuffer());
hasher.update(plaintext);
var cipher = await encrypt(
keys[1], plaintext, makeIv(1, index),
makeAad(1, profile, objectId, file.size, chunks, index, plaintext.byteLength)
);
await request(base, '/api/v1/uploads/' + uploadSession.uploadId + '/chunks/' + index, {
method: 'PUT',
headers: {'Content-Type': 'application/octet-stream', 'Authorization': authorization},
body: cipher,
signal: signal
}, false);
progress(++completed / totalSteps);
}
var metadataName = safeName(file.name);
var metadataValue = {
v: 1,
name: metadataName,
type: typeFor(file.type, metadataName).slice(0, 255),
size: file.size,
lastModified: Number.isSafeInteger(file.lastModified) ? file.lastModified : null,
sha256: hasher.hex()
};
var metadata = encoder.encode(JSON.stringify(metadataValue));
if (metadata.length > 4096) throw new Error('File metadata is too large.');
var encryptedMetadata = await encrypt(
keys[2], metadata, makeIv(2, 0),
makeAad(0, profile, objectId, file.size, chunks, 0xffffffff, metadata.length)
);
await request(base, '/api/v1/uploads/' + uploadSession.uploadId + '/metadata', {
method: 'PUT',
headers: {'Content-Type': 'application/octet-stream', 'Authorization': authorization},
body: encryptedMetadata,
signal: signal
}, false);
progress(++completed / totalSteps);
var committed = await request(base, '/api/v1/uploads/' + uploadSession.uploadId + '/commit', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Authorization': authorization},
body: '{}',
signal: signal
}, true);
if (!committed || !Number.isSafeInteger(committed.expiresAt)) {
throw new Error('NeoDrop request failed');
}
progress(1);
return {
id: objectIdText,
readUrl: base + '/d/' + objectIdText + '?k=' + kindFor(metadataValue.type) + '#d1.' + b64url(secret),
deleteCapability: objectIdText + '.' + uploadSession.uploadToken,
name: metadataValue.name,
type: metadataValue.type,
size: file.size,
sha256: metadataValue.sha256,
profile: profile,
expiresAt: committed.expiresAt
};
} finally {
secret.fill(0);
}
}
return {
CHUNK_SIZE: CHUNK_SIZE,
SMALL_MAX: SMALL_MAX,
LARGE_MAX: LARGE_MAX,
upload: upload
};
}));
+27
View File
@@ -0,0 +1,27 @@
function neoDropFrame(url, kind) {
return function() {
var element = this.getElement();
var frame = document.createElement('iframe');
frame.src = url.replace('?k=', '?embed=1&k=');
frame.title = 'Encrypted NeoDrop ' + kind + ' preview';
frame.width = '100%';
frame.height = kind === 'audio' ? '80' : kind === 'text' ? '420' : '520';
frame.loading = 'lazy';
frame.referrerPolicy = 'no-referrer';
frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-downloads');
frame.setAttribute('frameborder', '0');
element.replaceChildren(frame);
};
}
var neoDropMediaPlugin = new UrlPlugin('NeoDrop media', function(url) {
var match = url.match(/^https:\/\/{{DROPS_HOST_REGEX}}\/d\/([A-Za-z0-9_-]{32})\?k=(image|audio|video)#d1\.([A-Za-z0-9_-]{43})$/);
return match ? neoDropFrame(url, match[2]) : null;
});
neoDropMediaPlugin.exclusive = true;
var neoDropTextPlugin = new UrlPlugin('NeoDrop preview', function(url) {
var match = url.match(/^https:\/\/{{DROPS_HOST_REGEX}}\/d\/([A-Za-z0-9_-]{32})\?k=text#d1\.([A-Za-z0-9_-]{43})$/);
return match ? neoDropFrame(url, 'text') : null;
});
neoDropTextPlugin.exclusive = true;
+62
View File
@@ -0,0 +1,62 @@
(function() {
'use strict';
var weechat = angular.module('weechat');
var neoDropCrypto = require('./neodrop-crypto');
weechat.factory('imgur', ['$rootScope', 'settings', function($rootScope, settings) {
var BASE = '{{DROPS_ORIGIN}}';
var showErrorMsg = function(message) {
$rootScope.uploadError = true;
$rootScope.uploadErrorMessage = message || 'Encrypted upload failed.';
if (!$rootScope.$$phase) $rootScope.$apply();
setTimeout(function() {
$rootScope.uploadError = false;
if (!$rootScope.$$phase) $rootScope.$apply();
}, 5000);
};
var process = function(file, callback) {
if (!file || typeof file.size !== 'number') return;
if (file.size > neoDropCrypto.LARGE_MAX) {
showErrorMsg('NeoDrop files are limited to 1 GiB.');
return;
}
var profile = (settings.dropProfile === 'large' || file.size > neoDropCrypto.SMALL_MAX) ? 1 : 0;
var progressBars = document.getElementById('imgur-upload-progress');
var bar = document.createElement('div');
bar.className = 'imgur-progress-bar';
progressBars.appendChild(bar);
neoDropCrypto.upload(file, {
base: BASE,
profile: profile,
onProgress: function(value) { bar.style.width = Math.round(value * 100) + '%'; }
}).then(function(result) {
if (typeof $rootScope.registerNeoDrop === 'function') {
$rootScope.registerNeoDrop(result.readUrl, result.deleteCapability);
}
if (callback) callback(result.readUrl, result.deleteCapability);
}).catch(function(error) {
showErrorMsg(error.message);
}).finally(function() {
setTimeout(function() { if (bar.parentNode) bar.parentNode.removeChild(bar); }, 500);
});
};
var deleteImage = function(capability, callback) {
var split = String(capability || '').split('.');
if (split.length !== 2) return showErrorMsg('Invalid deletion capability.');
fetch(BASE + '/api/v1/objects/' + split[0], {
method: 'DELETE', headers: {'Authorization': 'Bearer ' + split[1]}
}).then(function(response) {
if (!response.ok) throw new Error('Unable to delete encrypted file.');
if (callback) callback(capability);
}).catch(function() { showErrorMsg('Unable to delete encrypted file.'); });
};
return {process: process, deleteImage: deleteImage};
}]);
})();
+17
View File
@@ -0,0 +1,17 @@
<div class="gb-modal neo-server-manager" data-state="visible" ng-if="serverManager.visible">
<div class="backdrop" ng-click="closeNeoServerManager()"></div>
<div class="modal-dialog"><div class="modal-content">
<div class="modal-header"><button type="button" class="close" ng-click="closeNeoServerManager()">&times;</button><h4>IRC server manager</h4><p>Add a network or edit every WeeChat server option. Password fields are write-only.</p></div>
<div class="modal-body neo-server-layout">
<aside><button type="button" class="btn btn-primary neo-add-server" ng-click="newNeoServer()">Add new server</button><button type="button" ng-repeat="name in serverManager.servers" ng-click="selectNeoServer(name)" ng-bind="name"></button></aside>
<section ng-if="serverManager.selected">
<label>Server name<input class="form-control neo-server-control" ng-model="serverManager.selected.name" ng-disabled="!serverManager.isNew"></label>
<div class="neo-server-options">
<label ng-repeat="option in serverManager.selected.options" title="{{option.description}}"><span ng-bind="option.name"></span><select class="form-control neo-server-control" ng-if="option.boolean" ng-model="option.value" ng-change="option.dirty=true"><option value="on">On</option><option value="off">Off</option></select><input ng-if="!option.boolean" class="form-control neo-server-control" type="{{option.secret?'password':'text'}}" autocomplete="off" ng-model="option.value" ng-change="option.dirty=true" placeholder="{{option.secret?'Leave blank to keep existing value':''}}"><span class="inherit"><input type="checkbox" ng-model="option.inherited" ng-change="option.dirty=true" ng-disabled="serverManager.isNew||option.secret"> inherit</span></label>
</div>
</section>
<p class="status" ng-bind="serverManager.status"></p>
</div>
<div class="modal-footer"><button type="button" class="btn btn-default" ng-click="closeNeoServerManager()">Cancel</button><button type="button" class="btn btn-primary" ng-click="saveNeoServer(false)" ng-disabled="serverManager.saving">Save</button><button type="button" class="btn btn-primary" ng-click="saveNeoServer(true)" ng-disabled="serverManager.saving">Save and connect</button></div>
</div></div>
</div>
+56
View File
@@ -0,0 +1,56 @@
'use strict';
(function() {
var weechat = angular.module('weechat');
var names = 'addresses proxy ipv6 ssl ssl_cert ssl_password ssl_priorities ssl_dhkey_size ssl_fingerprint ssl_verify password capabilities sasl_mechanism sasl_username sasl_password sasl_key sasl_timeout sasl_fail autoconnect autoreconnect autoreconnect_delay nicks nicks_alternate username realname local_hostname usermode command command_delay autojoin autojoin_dynamic autorejoin autorejoin_delay connection_timeout anti_flood_prio_high anti_flood_prio_low away_check away_check_max_nicks msg_kick msg_part msg_quit notify split_msg_max_length charset_message default_chantypes'.split(' ');
var secrets = {ssl_password: true, password: true, sasl_password: true};
var booleans = {ipv6:true,ssl:true,ssl_verify:true,autoconnect:true,autoreconnect:true,nicks_alternate:true,autojoin_dynamic:true,autorejoin:true};
weechat.directive('neoServerManager', ['connection', function(connection) {
return {restrict: 'E', templateUrl: 'directives/server-manager.html', link: function(scope) {
scope.serverManager = {visible:false, loading:false, saving:false, servers:[], selected:null, isNew:false, status:''};
var manager = scope.serverManager;
function validName(value) { return /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/.test(value || ''); }
function safeValue(value) { return typeof value === 'string' && !/[\r\n\0"]/.test(value); }
function optionModel(name, row) { return {name:name, description:row&&row.description||'', value:row&&row.value_is_null?String(row.parent_value||''):String(row&&row.value||''), inherited:Boolean(row&&row.value_is_null), secret:Boolean(secrets[name]), boolean:Boolean(booleans[name]), dirty:false}; }
function refresh() {
manager.loading = true;
return connection.requestInfolist('option', 0, 'irc.server.*.addresses').then(function(rows) {
manager.servers = rows.map(function(row) { return row.full_name.slice(11, -10); }).filter(validName).sort();
manager.loading = false;
}, function() { manager.loading=false; manager.status='Unable to load server settings.'; });
}
function select(name) {
manager.isNew = false; manager.loading = true; manager.status = '';
Promise.all(names.map(function(option) {
if (secrets[option]) return Promise.resolve(optionModel(option, null));
return connection.requestInfolist('option', 0, 'irc.server.' + name + '.' + option).then(function(rows) { return optionModel(option, rows[0]); });
})).then(function(options) { scope.$applyAsync(function() { manager.selected={name:name, originalName:name, options:options}; manager.loading=false; }); });
}
scope.newNeoServer = function() { manager.isNew=true; manager.selected={name:'',originalName:'',options:names.map(function(name){return optionModel(name,null);})}; manager.selected.options[0].inherited=false; manager.status=''; };
scope.selectNeoServer = select;
scope.closeNeoServerManager = function() { manager.visible=false; manager.selected=null; };
scope.saveNeoServer = function(connectAfter) {
var server=manager.selected; if(!server||!validName(server.name)){manager.status='Use a safe server name.';return;}
var addresses=server.options[0].value; if(manager.isNew&&!safeValue(addresses)){manager.status='Enter a safe server address.';return;}
manager.saving=true;
if(manager.isNew) connection.sendCoreCommand('/server add '+server.name+' '+addresses);
server.options.forEach(function(option) {
if ((!manager.isNew&&!option.dirty)||(manager.isNew&&option.name==='addresses')||option.secret&&!option.value) return;
if (!safeValue(option.value)){manager.status='Values cannot contain quotes or control characters.';return;}
var full='irc.server.'+server.name+'.'+option.name;
if (option.secret) {
var secureKey='neobear_'+server.name.toLowerCase()+'_'+option.name;
connection.sendCoreCommand('/secure set '+secureKey+' '+option.value);
connection.sendCoreCommand('/set '+full+' "${sec.data.'+secureKey+'}"');
} else {
connection.sendCoreCommand(option.inherited?'/unset '+full:'/set '+full+' "'+option.value+'"');
}
});
connection.sendCoreCommand('/save irc');
if(connectAfter) connection.sendCoreCommand('/connect '+server.name);
manager.status='Changes sent to WeeChat. Check the core buffer for any rejected setting.'; manager.saving=false; refresh();
};
scope.$on('neoServerManagerOpen', function(event, name) { manager.visible=true; refresh().then(function(){ if(name) select(name); else scope.$applyAsync(scope.newNeoServer); }); });
}};
}]);
}());
+85
View File
@@ -0,0 +1,85 @@
$timeout(function() {
var sidebar = document.getElementById('sidebar');
if (!sidebar || document.getElementById('sidebar-resizer')) {
return;
}
var sidebarResizer = document.createElement('div');
var desktopSidebarMedia = window.matchMedia('(min-width: 969px)');
var activeSidebarPointer = null;
var workingSidebarWidth = 0;
sidebarResizer.id = 'sidebar-resizer';
sidebarResizer.tabIndex = 0;
sidebarResizer.setAttribute('role', 'separator');
sidebarResizer.setAttribute('aria-orientation', 'vertical');
sidebarResizer.setAttribute('aria-controls', 'sidebar');
sidebarResizer.setAttribute('aria-label', 'Resize channel sidebar');
sidebarResizer.setAttribute('aria-valuemin', '120');
sidebarResizer.setAttribute('aria-valuemax', '480');
sidebar.parentNode.insertBefore(sidebarResizer, sidebar.nextSibling);
var maximumSidebarWidth = function() {
return Math.min(480, window.innerWidth * 0.4);
};
var applySidebarWidth = function(value, limitToViewport) {
var width = Number(value);
if (!Number.isFinite(width) || width <= 0) {
return;
}
width = Math.max(120, Math.min(limitToViewport ? maximumSidebarWidth() : 480, width));
width = Math.round(width);
document.documentElement.style.setProperty('--neorelay-sidebar-preferred-width', width + 'px');
sidebarResizer.setAttribute('aria-valuenow', width);
workingSidebarWidth = width;
};
applySidebarWidth(settings.neorelaySidebarWidth, false);
sidebarResizer.addEventListener('pointerdown', function(event) {
if (!desktopSidebarMedia.matches || event.button !== 0) {
return;
}
activeSidebarPointer = event.pointerId;
workingSidebarWidth = Math.round(sidebar.getBoundingClientRect().width);
sidebarResizer.setPointerCapture(event.pointerId);
document.body.classList.add('neorelay-sidebar-resizing');
event.preventDefault();
});
sidebarResizer.addEventListener('pointermove', function(event) {
if (event.pointerId === activeSidebarPointer && desktopSidebarMedia.matches) {
applySidebarWidth(event.clientX, true);
}
});
var finishSidebarResize = function(event) {
if (event.pointerId !== activeSidebarPointer) {
return;
}
activeSidebarPointer = null;
document.body.classList.remove('neorelay-sidebar-resizing');
if (workingSidebarWidth > 0) {
settings.neorelaySidebarWidth = workingSidebarWidth;
}
};
sidebarResizer.addEventListener('pointerup', finishSidebarResize);
sidebarResizer.addEventListener('pointercancel', finishSidebarResize);
sidebarResizer.addEventListener('keydown', function(event) {
if (!desktopSidebarMedia.matches ||
(event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) {
return;
}
var direction = event.key === 'ArrowLeft' ? -1 : 1;
var step = event.shiftKey ? 25 : 10;
applySidebarWidth(sidebar.getBoundingClientRect().width + direction * step, true);
settings.neorelaySidebarWidth = workingSidebarWidth;
event.preventDefault();
});
}, 0);
+20
View File
@@ -0,0 +1,20 @@
<section class="neo-weechat-settings" ng-show="neoSettingsTab === 'weechat'" role="tabpanel" aria-label="WeeChat settings">
<div class="neo-settings-toolbar">
<label>Section<select class="form-control" ng-model="neoWeeChatSettings.section" ng-options="section.mask as section.label for section in neoWeeChatSettings.sections" ng-change="loadNeoWeeChatSettings()"></select></label>
<label>Filter<input class="form-control" type="text" ng-model="neoWeeChatSettings.filter" placeholder="Filter loaded settings"></label>
<button type="button" class="btn btn-default" ng-click="loadNeoWeeChatSettings()" ng-disabled="neoWeeChatSettings.loading">Refresh</button>
</div>
<p>Changes are validated, written to WeeChat, read back, and then saved to the corresponding configuration file.</p>
<p class="status" ng-bind="neoWeeChatSettings.status"></p>
<div class="neo-option-list">
<article class="neo-option" ng-repeat="option in neoWeeChatSettings.options | filter:neoWeeChatSettings.filter | limitTo:200 track by option.name">
<div><strong ng-bind="option.name"></strong><small ng-bind="option.description"></small></div>
<select class="form-control" ng-if="option.type === 'boolean'" ng-model="option.editValue"><option value="on">On</option><option value="off">Off</option></select>
<select class="form-control" ng-if="option.type !== 'boolean' && option.choices.length" ng-model="option.editValue" ng-options="choice for choice in option.choices"></select>
<select class="form-control" ng-if="option.type === 'color' && !option.choices.length" ng-model="option.editValue" ng-options="colour for colour in neoWeeChatColourChoices"></select>
<input class="form-control" ng-if="option.type === 'integer' && !option.choices.length" type="number" ng-model="option.editValue" min="{{option.min}}" max="{{option.max}}">
<input class="form-control" ng-if="option.type !== 'boolean' && option.type !== 'color' && option.type !== 'integer' && !option.choices.length" type="text" ng-model="option.editValue">
<div class="neo-option-actions"><button type="button" class="btn btn-primary" ng-click="saveNeoWeeChatOption(option)" ng-disabled="option.saving">Save</button><button type="button" class="btn btn-default" ng-click="resetNeoWeeChatOption(option)" ng-disabled="option.saving">Reset</button></div>
</article>
</div>
</section>
+150
View File
@@ -0,0 +1,150 @@
var neoBasicColours = ['default','black','darkgray','red','lightred','green','lightgreen','brown','yellow','blue','lightblue','magenta','lightmagenta','cyan','lightcyan','gray','white'];
$scope.neoWeeChatColourChoices = neoBasicColours.concat(Array.from({length: 256}, function(_, index) { return String(index); }));
var neoColourHex = {default:'#f1eaff',black:'#000000',darkgray:'#555555',red:'#aa0000',lightred:'#ff5555',green:'#00aa00',lightgreen:'#55ff55',brown:'#aa5500',yellow:'#ffff55',blue:'#0000aa',lightblue:'#5555ff',magenta:'#aa00aa',lightmagenta:'#ff55ff',cyan:'#00aaaa',lightcyan:'#55ffff',gray:'#aaaaaa',white:'#ffffff'};
var neoAllowedSections = [
{label:'WeeChat appearance',mask:'weechat.look.*'},
{label:'WeeChat colours',mask:'weechat.color.*'},
{label:'Completion',mask:'weechat.completion.*'},
{label:'History',mask:'weechat.history.*'},
{label:'Network',mask:'weechat.network.*'},
{label:'Plugins',mask:'weechat.plugin.*'},
{label:'IRC appearance',mask:'irc.look.*'},
{label:'IRC colours',mask:'irc.color.*'},
{label:'IRC network',mask:'irc.network.*'}
];
var neoAllowedMasks = neoAllowedSections.reduce(function(result, item) { result[item.mask] = true; return result; }, {});
var neoLoadedOptions = {};
function neoResolveWeeChatColour(value) {
value = String(value || 'default').replace(/^[*!/_|]+/, '');
if (neoColourHex[value]) return neoColourHex[value];
var number = Number(value);
if (!Number.isInteger(number) || number < 0 || number > 255) return neoColourHex.default;
if (number < 16) return neoColourHex[neoBasicColours[number + 1] || 'default'];
if (number >= 232) {
var gray = 8 + (number - 232) * 10;
return 'rgb(' + gray + ',' + gray + ',' + gray + ')';
}
number -= 16;
var levels = [0,95,135,175,215,255];
return 'rgb(' + levels[Math.floor(number / 36)] + ',' + levels[Math.floor(number / 6) % 6] + ',' + levels[number % 6] + ')';
}
function neoValidWeeChatColour(value) {
value = String(value || '').replace(/^[*!/_|]+/, '');
return neoBasicColours.indexOf(value) >= 0 || (/^(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(value));
}
function neoEffectiveOptionValue(row) {
if (!row.value_is_null) return Object.prototype.hasOwnProperty.call(row, 'value') ? String(row.value) : '';
if (Object.prototype.hasOwnProperty.call(row, 'parent_value')) return String(row.parent_value);
return Object.prototype.hasOwnProperty.call(row, 'default_value') ? String(row.default_value) : '';
}
function neoNormalizeOption(row) {
var choices = row.string_values ? String(row.string_values).split('|').filter(Boolean) : [];
var type = String(row.type || 'string');
var value = neoEffectiveOptionValue(row);
return {
name: String(row.full_name || ''),
configName: String(row.config_name || '').replace(/[^A-Za-z0-9_-]/g, ''),
description: String(row.description_nls || row.description || ''),
type: type,
value: value,
editValue: type === 'integer' && !choices.length && /^-?[0-9]+$/.test(value) ? Number(value) : value,
defaultValue: Object.prototype.hasOwnProperty.call(row, 'default_value') ? String(row.default_value) : '',
min: Number(row.min),
max: Number(row.max),
choices: choices,
saving: false
};
}
function neoSafeOption(row) {
return row && /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,511}$/.test(row.name) &&
/^(weechat|irc)$/.test(row.configName) &&
!/(?:password|passphrase|secret|token)(?:$|[._-])/i.test(row.name);
}
function neoValidateOption(row, value) {
value = String(value);
if (!neoSafeOption(row) || neoLoadedOptions[row.name] !== row) throw new Error('Setting is not editable.');
if (value.length > 4096 || /[\u0000-\u001f\u007f-\u009f"\\\u2028\u2029]/.test(value)) throw new Error('Value contains unsupported characters.');
if (row.type === 'boolean' && !/^(?:on|off)$/.test(value)) throw new Error('Choose On or Off.');
if (row.choices.length && row.choices.indexOf(value) < 0) throw new Error('Choose a listed value.');
if (row.type === 'integer' && !row.choices.length) {
if (!/^-?[0-9]+$/.test(value)) throw new Error('Enter an integer.');
var number = Number(value);
if ((Number.isFinite(row.min) && number < row.min) || (Number.isFinite(row.max) && row.max > row.min && number > row.max)) throw new Error('Integer is outside the allowed range.');
}
if (row.type === 'color' && !neoValidWeeChatColour(value)) throw new Error('Choose a WeeChat colour.');
if (row.name === 'irc.color.nick_prefixes') {
var prefixEntries = value.split(';').filter(Boolean);
if (!prefixEntries.length || prefixEntries.some(function(entry) {
var match = entry.match(/^[A-Za-z*]:([*!/_|]*)([^;]+)$/);
return !match || !neoValidWeeChatColour(match[2]);
})) throw new Error('Invalid IRC prefix colour map.');
}
return value;
}
function neoRefreshExact(row, expected, success, failure, attempt) {
connection.requestInfolist('option', 0, row.name).then(function(rows) {
var updated = rows[0] ? neoNormalizeOption(rows[0]) : null;
if (updated && (expected === null || updated.value === expected)) {
Object.assign(row, updated);
neoLoadedOptions[row.name] = row;
connection.sendCoreCommand('/save ' + updated.configName);
$scope.$applyAsync(function() { success(row); });
} else if (attempt < 5) {
$timeout(function() { neoRefreshExact(row, expected, success, failure, attempt + 1); }, 200 * attempt);
} else {
$scope.$applyAsync(function() { failure('WeeChat did not confirm the setting change.'); });
}
}, function() { $scope.$applyAsync(function() { failure('Unable to verify the setting change.'); }); });
}
function neoWriteOption(row, value, success, failure) {
try { value = neoValidateOption(row, value); }
catch (error) { failure(error.message); return; }
connection.sendCoreCommand('/set ' + row.name + ' "' + value + '"');
$timeout(function() { neoRefreshExact(row, value, success, failure, 1); }, 150);
}
$scope.neoWeeChatSettings = {sections: neoAllowedSections, section: neoAllowedSections[0].mask, filter: '', options: [], loading: false, status: ''};
$scope.loadNeoWeeChatSettings = function() {
var state = $scope.neoWeeChatSettings;
if (!$rootScope.connected || state.loading || !neoAllowedMasks[state.section]) return;
state.loading = true;
state.status = 'Loading settings from WeeChat...';
connection.requestInfolist('option', 0, state.section).then(function(rows) {
$scope.$applyAsync(function() {
state.options = rows.map(neoNormalizeOption).filter(neoSafeOption).sort(function(a, b) { return a.name.localeCompare(b.name); });
state.options.forEach(function(row) { neoLoadedOptions[row.name] = row; });
state.loading = false;
state.status = 'Loaded ' + state.options.length + ' editable settings.';
});
}, function() { $scope.$applyAsync(function() { state.loading = false; state.status = 'Unable to load settings.'; }); });
};
$scope.saveNeoWeeChatOption = function(row) {
row.saving = true;
neoWriteOption(row, row.editValue, function(updated) {
Object.assign(row, updated);
row.saving = false;
$scope.neoWeeChatSettings.status = 'Saved ' + row.name + '.';
}, function(message) { row.saving = false; $scope.neoWeeChatSettings.status = message; });
};
$scope.resetNeoWeeChatOption = function(row) {
if (!neoSafeOption(row) || neoLoadedOptions[row.name] !== row) return;
row.saving = true;
connection.sendCoreCommand('/unset ' + row.name);
$timeout(function() {
neoRefreshExact(row, null, function(updated) {
Object.assign(row, updated);
row.saving = false;
$scope.neoWeeChatSettings.status = 'Reset ' + row.name + '.';
}, function(message) { row.saving = false; $scope.neoWeeChatSettings.status = message; }, 1);
}, 150);
};