android: a name of your own for anyone on the network
The desktop's local contact metadata on the phone: a peer's sheet ends in Name and notes — a petname, a group, a private note and the Favorite and Trusted marks, kept in the app's own config and published nowhere. The petname stands in for the username on the buddy list, in the chat list, on a thread's bar, in the peer sheet, in a room's roster and on a room line, and the real username stays with it — on the line beneath, or beside the name in a room — so a stranger is never quietly turned into someone you know. Favourites sit at the top of the buddy list, and clearing a person's details drops the entry rather than storing an empty one — the backend's own rule.
This commit is contained in:
1 parent
f792d7955d
commit
7eae0ee945
10 files changed
+211
-16
No files matched your search
@@ -381,6 +381,8 @@ const MOCK_USER = "mockuser";
|
||||
const ROOMS = [["Lounge", 412], ["Electronic", 98], ["Vinyl Rips", 37], ["Jazz", 61], ["Lossless Only", 23], ...Array.from({ length: 90 }, (_, i) => ["Room " + (i + 1), 90 - i])];
|
||||
const PEOPLE = ["ada", "bo", "cy", "dee"];
|
||||
const LINES = ["anyone got the 2024 remaster?", "the vinyl rip is warmer imo", "that folder is complete btw", "brb"];
|
||||
/* one person already named, so a cold read of the list has something in it */
|
||||
const CONTACTS = [{ username: "dusty_crates", petname: "Dusty the digger", group: "Traders", note: "always has the vinyl rips", trusted: true, favorite: false }];
|
||||
const PEERS = [
|
||||
{ username: "hifi_hank", slot: true, speed: 2400, queue: 0, ext: "flac", depth: 24, rate: 96000 },
|
||||
{ username: "dusty_crates", slot: true, speed: 900, queue: 0, ext: "flac", depth: 16, rate: 44100 },
|
||||
@@ -579,6 +581,17 @@ function mockNet(cmd, args) {
|
||||
if (cmd === "buddy_list") return Promise.resolve(["ada", "cy"]);
|
||||
if (cmd === "buddy_add") return Promise.resolve(["ada", "cy", args?.username].filter(Boolean));
|
||||
if (cmd === "buddy_remove") return Promise.resolve(["ada", "cy"].filter((u) => u !== args?.username));
|
||||
if (cmd === "contacts_meta_list") return Promise.resolve(CONTACTS.slice());
|
||||
if (cmd === "contact_meta_set") {
|
||||
/* the backend's own rule: an entry with nothing set is dropped */
|
||||
const meta = args?.meta || {};
|
||||
const txt = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
|
||||
const i = CONTACTS.findIndex((c) => c.username === meta.username);
|
||||
if (i >= 0) CONTACTS.splice(i, 1);
|
||||
const kept = { username: meta.username, petname: txt(meta.petname), group: txt(meta.group), note: txt(meta.note), trusted: !!meta.trusted, favorite: !!meta.favorite };
|
||||
if (kept.petname || kept.group || kept.note || kept.trusted || kept.favorite) CONTACTS.push(kept);
|
||||
return Promise.resolve(CONTACTS.slice());
|
||||
}
|
||||
if (cmd === "slsk_room_list") {
|
||||
later(200, () => mockEmit("soulseek:room-list", ROOMS.map(([name, users]) => ({ name, users }))));
|
||||
return Promise.resolve();
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js";
|
||||
import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics } from "./lyrics.svelte.js";
|
||||
import { art, wantArt } from "./art.svelte.js";
|
||||
import { metaOf, display, realName, setMeta, clearMeta } from "./contacts.svelte.js";
|
||||
import { downloadsAllowed } from "./network.svelte.js";
|
||||
import { note } from "./notice.svelte.js";
|
||||
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur, mmss, titleFromName } from "./util.js";
|
||||
@@ -208,6 +209,16 @@
|
||||
note(`Saving ${g.files.length} file${g.files.length === 1 ? "" : "s"} from ${g.username}`);
|
||||
} catch (e) { oops(e); }
|
||||
}
|
||||
/* the contact editor's three fields go through the prompt sheet. The
|
||||
username travels as a VALUE, the way the playlist prompts do: the
|
||||
sheet switches kind under the prompt, so the contact block's
|
||||
{@const} is a destroyed derived by the time Save lands. */
|
||||
function metaPrompt(u, field, title, placeholder, value, multiline = false) {
|
||||
openHalf("prompt", {
|
||||
title, value, placeholder, ok: "Save", multiline, allowEmpty: true,
|
||||
onsubmit: (v) => { void setMeta(u, { [field]: v }); openHalf("contact", { username: u }); },
|
||||
});
|
||||
}
|
||||
/* the desktop's per-record cover refresh; the thumbnail is asked for again */
|
||||
async function refetchCover(album) {
|
||||
try {
|
||||
@@ -501,9 +512,10 @@
|
||||
{@const u = shown.arg.username}
|
||||
{@const g = shown.arg.group || null}
|
||||
{@const buddy = slsk.buddies.includes(u)}
|
||||
{@const m = metaOf(u)}
|
||||
<div class="sheet-head">
|
||||
<span class="art idc"><Identicon seed={u} size={44} /></span>
|
||||
<span class="b"><span class="t">{u}</span><span class="m">{g ? (g.slot ? "slot free" : "queue " + g.queue) + " · " + speedLabel(g.speed) + " · " + g.folderName : "on Soulseek"}</span></span>
|
||||
<span class="b"><span class="t">{display(u)}{#if m.favorite}<span class="nmark fav"><Icon name="star" size={12} /></span>{/if}{#if m.trusted}<span class="nmark tr"><Icon name="shield" size={12} /></span>{/if}</span><span class="m">{[realName(u), m.group, g ? (g.slot ? "slot free" : "queue " + g.queue) + " · " + speedLabel(g.speed) + " · " + g.folderName : "on Soulseek"].filter(Boolean).join(" · ")}</span></span>
|
||||
</div>
|
||||
<div class="sheet-rows">
|
||||
{#if g}
|
||||
@@ -519,6 +531,35 @@
|
||||
{/if}
|
||||
{@render row("message", "Message", "", act(() => { openThread(u); push("thread", { username: u }); }))}
|
||||
{@render row(buddy ? "x" : "heart", buddy ? "Remove buddy" : "Add buddy", buddy ? "" : "buddies stay on the Community screen", act(() => (buddy ? removeBuddy : addBuddy)(u).catch(oops)))}
|
||||
<div class="sheet-hair"></div>
|
||||
{@render row("pencil", "Name and notes", "a petname, a group, a note — this phone only", () => openHalf("contact", { username: u }))}
|
||||
</div>
|
||||
{:else if kind === "contact" && shown?.arg?.username}
|
||||
{@const u = shown.arg.username}
|
||||
{@const m = metaOf(u)}
|
||||
<div class="sheet-head">
|
||||
<span class="art idc"><Identicon seed={u} size={44} /></span>
|
||||
<span class="b"><span class="t">{display(u)}</span><span class="m">{realName(u) ? realName(u) + " · " : ""}kept on this phone, published nowhere</span></span>
|
||||
</div>
|
||||
<div class="sheet-rows">
|
||||
{@render row("pencil", "Petname", m.petname || "what you call them here", () => metaPrompt(u, "petname", "Petname", u, m.petname || ""))}
|
||||
{@render row("tag", "Group", m.group || "a word to sort them by", () => metaPrompt(u, "group", "Group", "Traders", m.group || ""))}
|
||||
{@render row("file-text", "Note", m.note || "a private note, for you only", () => metaPrompt(u, "note", "Note", "What you want to remember about them", m.note || "", true))}
|
||||
<div class="sheet-hair"></div>
|
||||
<button class="row" onclick={() => setMeta(u, { favorite: !m.favorite })}>
|
||||
<span class="lead"><Icon name="star" size={20} /></span>
|
||||
<span class="b"><span class="t">Favorite</span><span class="m">kept at the top of the buddy list</span></span>
|
||||
<span class="sw" class:on={m.favorite}><span></span></span>
|
||||
</button>
|
||||
<button class="row" onclick={() => setMeta(u, { trusted: !m.trusted })}>
|
||||
<span class="lead"><Icon name="shield" size={20} /></span>
|
||||
<span class="b"><span class="t">Trusted</span><span class="m">someone you take at their word</span></span>
|
||||
<span class="sw" class:on={m.trusted}><span></span></span>
|
||||
</button>
|
||||
{#if m.petname || m.group || m.note || m.trusted || m.favorite}
|
||||
<div class="sheet-hair"></div>
|
||||
{@render row("x", "Clear these details", "everything above leaves the list", act(() => { void clearMeta(u); }))}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if (kind === "catalogAlbum-more" || kind === "catalogAlbum") && catPage}
|
||||
{@const al = catPage.album}
|
||||
@@ -634,7 +675,7 @@
|
||||
{@const buddy = slsk.buddies.includes(u.username)}
|
||||
<button class="row" onclick={act(() => { openThread(u.username); push("thread", { username: u.username }); })} oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: u.username }); }}>
|
||||
<span class="lead"><span class="dot" class:off={u.status === 0} class:warn={u.status === 1}></span></span>
|
||||
<span class="b"><span class="t">{u.username}{#if buddy} <span class="tag hi">BUDDY</span>{/if}</span><span class="m">{u.file_count ? u.file_count.toLocaleString() + " files" : ""}</span></span>
|
||||
<span class="b"><span class="t">{display(u.username)}{#if buddy} <span class="tag hi">BUDDY</span>{/if}</span><span class="m">{[realName(u.username), u.file_count ? u.file_count.toLocaleString() + " files" : ""].filter(Boolean).join(" · ")}</span></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
import { importer, bindImporter } from "./importer.svelte.js";
|
||||
import { bindShare } from "./share.svelte.js";
|
||||
import { downloadsAllowed, cellular } from "./network.svelte.js";
|
||||
import { contacts, loadContacts, display } from "./contacts.svelte.js";
|
||||
import * as sources from "../lib/sources.js";
|
||||
import { greeting, drag } from "./util.js";
|
||||
|
||||
@@ -68,7 +69,7 @@
|
||||
case "library": return { title: "Your library", kicker: "Library" };
|
||||
case "community": return { title: "Community", kicker: slsk.connection === "online" ? "online as " + slsk.username : "Soulseek" };
|
||||
case "room": return { title: "#" + e.c.name, kicker: "Room", flush: true };
|
||||
case "thread": return { title: e.c.username, kicker: "Chat", flush: true };
|
||||
case "thread": return { title: display(e.c.username), kicker: "Chat", flush: true };
|
||||
case "you": return { title: "You", kicker: "You" };
|
||||
case "transfers": return { title: "Transfers", kicker: "Downloads" };
|
||||
case "import": return { title: "Import", kicker: importer.draft?.source_name || "Playlist" };
|
||||
@@ -188,13 +189,14 @@
|
||||
bindHome();
|
||||
bindImporter();
|
||||
bindShare();
|
||||
loadContacts();
|
||||
/* the desktop's library art prewarm: every cover cut to the cards'
|
||||
edge, one decode at a time behind the tiles' gate, a moment after
|
||||
boot so the first screen and a restored track come first */
|
||||
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
|
||||
/* the probe handle — the desktop has __soulstream for the same reason:
|
||||
tests/mobile/smoke.mjs drives the real app through it */
|
||||
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, pls, notice, importer, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch };
|
||||
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, pls, notice, importer, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts };
|
||||
return () => { if (unlisten) unlisten(); };
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/* The local view of a person on the network: a petname, a group, a private
|
||||
note and the two marks. All of it lives in this app's own config and is
|
||||
published nowhere — Soulseek never learns it, and neither does the person
|
||||
it describes. The desktop keeps the same index by username
|
||||
(src/lib/community.svelte.js), and the same rule: the backend drops an
|
||||
entry with nothing set, so the list it answers with is the one we take. */
|
||||
import * as api from "../lib/api.js";
|
||||
import { player } from "./player.svelte.js";
|
||||
|
||||
export const contacts = $state({
|
||||
/** username → { username, petname, group, note, trusted, favorite } */
|
||||
by: {},
|
||||
/** the list came back; false until it does, and after a failure */
|
||||
loaded: false,
|
||||
error: "",
|
||||
});
|
||||
|
||||
const BLANK = { petname: null, group: null, note: null, trusted: false, favorite: false };
|
||||
/** what the backend stores: a trimmed string, or nothing at all */
|
||||
const trimmed = (v) => { const s = typeof v === "string" ? v.trim() : v; return s ? s : null; };
|
||||
|
||||
function index(list) {
|
||||
contacts.by = Object.fromEntries((list || []).map((c) => [c.username, c]));
|
||||
contacts.loaded = true;
|
||||
contacts.error = "";
|
||||
}
|
||||
|
||||
let inflight = null;
|
||||
/** Read the whole list — once at boot, and again whenever anyone asks. A
|
||||
* failure is quiet: a petname is an enhancement, not the screen. */
|
||||
export function loadContacts() {
|
||||
if (inflight) return inflight;
|
||||
inflight = api
|
||||
.contactsMetaList()
|
||||
.then(index)
|
||||
.catch((e) => { contacts.loaded = false; contacts.error = String(e); })
|
||||
.finally(() => { inflight = null; });
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export const metaOf = (u) => contacts.by[u] ?? { username: u, ...BLANK };
|
||||
/** The name to show: the petname when there is one, the username otherwise. */
|
||||
export const display = (u) => contacts.by[u]?.petname || u;
|
||||
/** The username, but only while a petname stands in front of it — a stranger
|
||||
* is never quietly turned into someone you know. */
|
||||
export const realName = (u) => (contacts.by[u]?.petname ? u : "");
|
||||
|
||||
/** Upsert one person. The answer is the whole list after the write, so it is
|
||||
* taken as it comes rather than patched into our own copy: that is how an
|
||||
* entry with nothing left in it disappears. A failure goes to the bar. */
|
||||
export async function setMeta(username, patch) {
|
||||
const m = { ...metaOf(username), ...patch };
|
||||
try {
|
||||
index(
|
||||
await api.contactMetaSet({
|
||||
username,
|
||||
petname: trimmed(m.petname),
|
||||
group: trimmed(m.group),
|
||||
note: trimmed(m.note),
|
||||
trusted: !!m.trusted,
|
||||
favorite: !!m.favorite,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
player.error = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything blank: the entry leaves the list rather than being stored empty. */
|
||||
export const clearMeta = (u) => setMeta(u, { petname: "", group: "", note: "", trusted: false, favorite: false });
|
||||
@@ -7,6 +7,7 @@
|
||||
import Identicon from "../../components/Identicon.svelte";
|
||||
import { slsk, connect, connectStored, disconnect, refreshRooms, joinRoom, leaveRoom, openThread, unreadTotal } from "../slsk.svelte.js";
|
||||
import { push, patch, openHalf } from "../nav.svelte.js";
|
||||
import { metaOf, display, realName } from "../contacts.svelte.js";
|
||||
import { player } from "../player.svelte.js";
|
||||
import { ago } from "../util.js";
|
||||
|
||||
@@ -36,7 +37,12 @@
|
||||
.map(([u, c]) => ({ u, c, last: c.messages[c.messages.length - 1] }))
|
||||
.sort((a, b) => (b.last?.ts || 0) - (a.last?.ts || 0)),
|
||||
);
|
||||
const buddies = $derived(slsk.buddies.map((u) => ({ u, st: slsk.buddyState[u] || {} })));
|
||||
/* favourites at the top, then by the name you gave them */
|
||||
const buddies = $derived(
|
||||
slsk.buddies
|
||||
.map((u) => ({ u, st: slsk.buddyState[u] || {}, m: metaOf(u) }))
|
||||
.sort((a, b) => (b.m.favorite ? 1 : 0) - (a.m.favorite ? 1 : 0) || display(a.u).localeCompare(display(b.u))),
|
||||
);
|
||||
/* the hub's browse-by-name: a typed name opens the peer sheet */
|
||||
function lookUp() {
|
||||
openHalf("prompt", { title: "Look up a user", placeholder: "Soulseek username", ok: "Open", onsubmit: (v) => { const u = v.trim(); if (u) openHalf("peer", { username: u }); } });
|
||||
@@ -157,8 +163,8 @@
|
||||
<button class="row" onclick={() => thread(t.u)}>
|
||||
<span class="art round idc"><Identicon seed={t.u} size={46} /></span>
|
||||
<span class="b">
|
||||
<span class="t">{t.u}{#if t.c.unread} <span class="unread">{t.c.unread}</span>{/if}</span>
|
||||
<span class="m">{t.last ? (t.last.me ? "you: " : "") + t.last.text : ""}</span>
|
||||
<span class="t">{display(t.u)}{#if t.c.unread} <span class="unread">{t.c.unread}</span>{/if}</span>
|
||||
<span class="m">{realName(t.u) ? realName(t.u) + " · " : ""}{t.last ? (t.last.me ? "you: " : "") + t.last.text : ""}</span>
|
||||
</span>
|
||||
<span class="r">{t.last ? ago(t.last.ts / 1000) : ""}</span>
|
||||
</button>
|
||||
@@ -173,11 +179,11 @@
|
||||
<div class="stub"><b>No buddies on this account yet.</b>Hold a name in a room to add one, or look one up above.</div>
|
||||
{/if}
|
||||
{#each buddies as b (b.u)}
|
||||
<button class="row" onclick={() => thread(b.u)} oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: b.u }); }}>
|
||||
<button class="row bud" onclick={() => thread(b.u)} oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: b.u }); }}>
|
||||
<span class="art round idc"><Identicon seed={b.u} size={46} /></span>
|
||||
<span class="b">
|
||||
<span class="t">{b.u}</span>
|
||||
<span class="m">{status(b.st)}{b.st.file_count ? ` · ${b.st.file_count.toLocaleString()} files` : ""}</span>
|
||||
<span class="t">{display(b.u)}{#if b.m.favorite}<span class="nmark fav"><Icon name="star" size={12} /></span>{/if}{#if b.m.trusted}<span class="nmark tr"><Icon name="shield" size={12} /></span>{/if}</span>
|
||||
<span class="m">{[realName(b.u), status(b.st), b.m.group, b.st.file_count ? `${b.st.file_count.toLocaleString()} files` : ""].filter(Boolean).join(" · ")}</span>
|
||||
</span>
|
||||
<span class="r"><span class="dot" class:off={status(b.st) === "offline"} class:warn={status(b.st) === "away"}></span></span>
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { tick } from "svelte";
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import { slsk, sayRoom, leaveRoom, openThread, addBuddy } from "../slsk.svelte.js";
|
||||
import { display, realName } from "../contacts.svelte.js";
|
||||
import { push, back, openHalf } from "../nav.svelte.js";
|
||||
import { player } from "../player.svelte.js";
|
||||
import { clock } from "../util.js";
|
||||
@@ -52,7 +53,7 @@
|
||||
{#each lines as l, i (i)}
|
||||
{@const me = l.username === slsk.username}
|
||||
<div class="msg" class:me>
|
||||
{#if !me}<button class="who" onclick={() => person(l.username)}>{l.username}</button>{/if}
|
||||
{#if !me}<button class="who" onclick={() => person(l.username)}>{display(l.username)}{#if realName(l.username)}<span class="real">{realName(l.username)}</span>{/if}</button>{/if}
|
||||
<div class="bub">{l.message}</div>
|
||||
<div class="at">{clock(l.ts)}</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import Identicon from "../../components/Identicon.svelte";
|
||||
import { slsk, sendDm, openThread } from "../slsk.svelte.js";
|
||||
import { display, realName } from "../contacts.svelte.js";
|
||||
import { player } from "../player.svelte.js";
|
||||
import { clock } from "../util.js";
|
||||
|
||||
@@ -36,11 +37,11 @@
|
||||
|
||||
<div class="room-head">
|
||||
<span class="art round idc"><Identicon seed={username} size={40} /></span>
|
||||
<span class="b"><span class="t-sub">{st.status === 2 || st.status === "online" ? "online" : st.status === 1 || st.status === "away" ? "away" : "on Soulseek"}{st.file_count ? ` · ${st.file_count.toLocaleString()} files` : ""}</span></span>
|
||||
<span class="b"><span class="t-sub">{[realName(username), st.status === 2 || st.status === "online" ? "online" : st.status === 1 || st.status === "away" ? "away" : "on Soulseek", st.file_count ? `${st.file_count.toLocaleString()} files` : ""].filter(Boolean).join(" · ")}</span></span>
|
||||
</div>
|
||||
<div class="chat">
|
||||
{#if !lines.length}
|
||||
<div class="sec-note" style="padding: 20px 0">Say something to {username}.</div>
|
||||
<div class="sec-note" style="padding: 20px 0">Say something to {display(username)}.</div>
|
||||
{/if}
|
||||
{#each lines as l, i (i)}
|
||||
<div class="msg" class:me={l.me}>
|
||||
@@ -50,7 +51,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
<div class="composer">
|
||||
<input class="field" bind:this={inputEl} bind:value={text} placeholder="Message {username}…" autocomplete="off" enterkeyhint="send" onkeydown={(e) => { if (e.key === "Enter") { e.preventDefault(); send(); } }} />
|
||||
<input class="field" bind:this={inputEl} bind:value={text} placeholder="Message {display(username)}…" autocomplete="off" enterkeyhint="send" onkeydown={(e) => { if (e.key === "Enter") { e.preventDefault(); send(); } }} />
|
||||
<button class="disc sm" onclick={send} aria-label="Send" disabled={!text.trim()}><Icon name="send" size={16} /></button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1369,6 +1369,14 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
|
||||
/* Community: the room filter sits with the section, not the page's own field */
|
||||
.sfield.roomfilter { margin-top: 2px; }
|
||||
|
||||
/* the two local marks beside a name: a star for a favourite, a shield for
|
||||
someone trusted. Both are this phone's own; neither is published. */
|
||||
.nmark { display: inline-flex; vertical-align: -2px; margin-left: 5px; color: var(--text-muted); }
|
||||
.nmark.fav { color: #e8a33d; }
|
||||
.nmark.tr { color: #3ddc84; }
|
||||
/* a room line whose author wears a petname keeps their own name beside it */
|
||||
.msg .who .real { margin-left: 5px; font-weight: 600; opacity: 0.65; }
|
||||
|
||||
/* the nav's badges: transfers in flight on You, unread chats on Community */
|
||||
.nav button .gl .nb { position: absolute; top: -3px; right: -12px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; background: var(--accent); color: #04121a; font-size: 10px; font-weight: 800; line-height: 16px; text-align: center; box-shadow: 0 0 0 2px var(--nav-bg, #101014); }
|
||||
|
||||
|
||||
Reference in new issue
Block a user