diff --git a/eslint.config.js b/eslint.config.js
index 50991ca..a608f06 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,44 +1,44 @@
-import prettier from 'eslint-config-prettier';
-import path from 'node:path';
-import { includeIgnoreFile } from '@eslint/compat';
-import js from '@eslint/js';
-import svelte from 'eslint-plugin-svelte';
-import { defineConfig } from 'eslint/config';
-import globals from 'globals';
-import ts from 'typescript-eslint';
-import svelteConfig from './svelte.config.js';
+import prettier from "eslint-config-prettier";
+import path from "node:path";
+import { includeIgnoreFile } from "@eslint/compat";
+import js from "@eslint/js";
+import svelte from "eslint-plugin-svelte";
+import { defineConfig } from "eslint/config";
+import globals from "globals";
+import ts from "typescript-eslint";
+import svelteConfig from "./svelte.config.js";
-const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
+const gitignorePath = path.resolve(import.meta.dirname, ".gitignore");
export default defineConfig(
- includeIgnoreFile(gitignorePath),
- js.configs.recommended,
- ts.configs.recommended,
- svelte.configs.recommended,
- prettier,
- svelte.configs.prettier,
- {
- languageOptions: { globals: { ...globals.browser, ...globals.node } },
- rules: {
- // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
- // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
- "no-undef": 'off'
- }
- },
- {
- files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
- languageOptions: {
- parserOptions: {
- projectService: true,
- extraFileExtensions: ['.svelte'],
- parser: ts.parser,
- svelteConfig
- }
- }
- },
- {
- // Override or add rule settings here, such as:
- // 'svelte/button-has-type': 'error'
- rules: {}
- }
+ includeIgnoreFile(gitignorePath),
+ js.configs.recommended,
+ ts.configs.recommended,
+ svelte.configs.recommended,
+ prettier,
+ svelte.configs.prettier,
+ {
+ languageOptions: { globals: { ...globals.browser, ...globals.node } },
+ rules: {
+ // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
+ // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
+ "no-undef": "off",
+ },
+ },
+ {
+ files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
+ languageOptions: {
+ parserOptions: {
+ projectService: true,
+ extraFileExtensions: [".svelte"],
+ parser: ts.parser,
+ svelteConfig,
+ },
+ },
+ },
+ {
+ // Override or add rule settings here, such as:
+ // 'svelte/button-has-type': 'error'
+ rules: {},
+ },
);
diff --git a/package.json b/package.json
index 7ae9eea..8327b54 100644
--- a/package.json
+++ b/package.json
@@ -1,46 +1,46 @@
{
- "name": "squalk",
- "private": true,
- "version": "0.0.1",
- "type": "module",
- "scripts": {
- "dev": "vite dev",
- "build": "vite build",
- "preview": "vite preview",
- "prepare": "svelte-kit sync || echo ''",
- "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
- "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
- "lint": "prettier --check . && eslint .",
- "format": "prettier --write ."
- },
- "devDependencies": {
- "@eslint/compat": "^2.0.4",
- "@eslint/js": "^10.0.1",
- "@sveltejs/adapter-auto": "^7.0.1",
- "@sveltejs/adapter-static": "^3.0.10",
- "@sveltejs/kit": "^2.57.0",
- "@sveltejs/vite-plugin-svelte": "^7.0.0",
- "@tailwindcss/forms": "^0.5.11",
- "@tailwindcss/typography": "^0.5.19",
- "@tailwindcss/vite": "^4.2.2",
- "@types/node": "^22",
- "eslint": "^10.2.0",
- "eslint-config-prettier": "^10.1.8",
- "eslint-plugin-svelte": "^3.17.0",
- "globals": "^17.4.0",
- "prettier": "^3.8.1",
- "prettier-plugin-svelte": "^3.5.1",
- "prettier-plugin-tailwindcss": "^0.7.2",
- "svelte": "^5.55.2",
- "svelte-check": "^4.4.6",
- "tailwindcss": "^4.2.2",
- "typescript": "^6.0.2",
- "typescript-eslint": "^8.58.1",
- "vite": "^8.0.7"
- },
- "dependencies": {
- "@djot/djot": "^0.3.2",
- "@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
- "@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3"
- }
+ "name": "squalk",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "prepare": "svelte-kit sync || echo ''",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+ "lint": "prettier --check . && eslint .",
+ "format": "prettier --write ."
+ },
+ "devDependencies": {
+ "@eslint/compat": "^2.0.4",
+ "@eslint/js": "^10.0.1",
+ "@sveltejs/adapter-auto": "^7.0.1",
+ "@sveltejs/adapter-static": "^3.0.10",
+ "@sveltejs/kit": "^2.57.0",
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
+ "@tailwindcss/forms": "^0.5.11",
+ "@tailwindcss/typography": "^0.5.19",
+ "@tailwindcss/vite": "^4.2.2",
+ "@types/node": "^22",
+ "eslint": "^10.2.0",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-svelte": "^3.17.0",
+ "globals": "^17.4.0",
+ "prettier": "^3.8.1",
+ "prettier-plugin-svelte": "^3.5.1",
+ "prettier-plugin-tailwindcss": "^0.7.2",
+ "svelte": "^5.55.2",
+ "svelte-check": "^4.4.6",
+ "tailwindcss": "^4.2.2",
+ "typescript": "^6.0.2",
+ "typescript-eslint": "^8.58.1",
+ "vite": "^8.0.7"
+ },
+ "dependencies": {
+ "@djot/djot": "^0.3.2",
+ "@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
+ "@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3"
+ }
}
diff --git a/src/app.d.ts b/src/app.d.ts
index da08e6d..520c421 100644
--- a/src/app.d.ts
+++ b/src/app.d.ts
@@ -1,13 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
- namespace App {
- // interface Error {}
- // interface Locals {}
- // interface PageData {}
- // interface PageState {}
- // interface Platform {}
- }
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
}
export {};
diff --git a/src/app.html b/src/app.html
index 6a2bb58..b6a3073 100644
--- a/src/app.html
+++ b/src/app.html
@@ -1,12 +1,12 @@
-
-
-
-
- %sveltekit.head%
-
-
- %sveltekit.body%
-
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
diff --git a/src/lib/admins.svelte.ts b/src/lib/admins.svelte.ts
index 5377389..bbe78e4 100644
--- a/src/lib/admins.svelte.ts
+++ b/src/lib/admins.svelte.ts
@@ -29,6 +29,18 @@ export const adminPubkeys = {
},
};
+// Per-group admin check. NIP-29 roles are scoped to a group, so a delete
+// button must gate on the post's own group, not the cross-room union above.
+export function isGroupAdmin(
+ pubkey: string | undefined,
+ groupId: string | undefined,
+): boolean {
+ if (!pubkey || !groupId) return false;
+ return MODE === "full"
+ ? (byRoom[groupId]?.includes(pubkey) ?? false)
+ : (groupStore.data?.admins?.includes(pubkey) ?? false);
+}
+
export async function loadRoomAdmins(roomIds: string[]) {
if (roomIds.length === 0) return;
const key = [...roomIds].sort().join(",");
diff --git a/src/lib/chat.svelte.ts b/src/lib/chat.svelte.ts
index 33343c7..fa96e3e 100644
--- a/src/lib/chat.svelte.ts
+++ b/src/lib/chat.svelte.ts
@@ -34,6 +34,10 @@ export function getChatMessage(id: string): ChatMessageData | undefined {
return messages.find((m) => m.id === id);
}
+export function removeChatMessage(id: string) {
+ messages = messages.filter((m) => m.id !== id);
+}
+
async function loadProfile(pubkey: string) {
if (profiles[pubkey]) return;
try {
diff --git a/src/lib/components/ChatSidebar.svelte b/src/lib/components/ChatSidebar.svelte
index a4b3123..728b41f 100644
--- a/src/lib/components/ChatSidebar.svelte
+++ b/src/lib/components/ChatSidebar.svelte
@@ -4,9 +4,12 @@
chatStore,
getChatMessage,
sendChatMessage,
+ removeChatMessage,
type ChatMessageData,
} from "$lib/chat.svelte";
import { auth, openLogin } from "$lib/auth.svelte";
+ import { isGroupAdmin } from "$lib/admins.svelte";
+ import { requestDelete } from "$lib/moderation.svelte";
import { withJoin } from "$lib/join.svelte";
import { activeGroup } from "$lib/active.svelte";
import type { NostrUser } from "@nostr/gadgets/metadata";
@@ -25,6 +28,9 @@
let listEl = $state(null);
let inputEl = $state(null);
let openMenuId = $state(null);
+ let menuPos = $state<{ top?: number; bottom?: number; right: number } | null>(
+ null,
+ );
let replyTarget = $state(null);
let inputValue = $state("");
let sending = $state(false);
@@ -33,6 +39,36 @@
const messages = $derived(chatStore.messages);
const profiles = $derived(chatStore.profiles);
+ const canModerate = $derived(
+ !!auth.user && isGroupAdmin(auth.user.pubkey, activeGroup.id),
+ );
+
+ function requestDeleteMessage(msg: ChatMessageData) {
+ openMenuId = null;
+ requestDelete(
+ { eventId: msg.id, groupId: activeGroup.id, label: "message" },
+ () => removeChatMessage(msg.id),
+ );
+ }
+
+ // The message list clips overflow, so an absolute menu gets cut off by the top
+ // bar or the input. Anchor it with fixed coords from the button instead,
+ // opening upward unless there isn't room above.
+ function toggleMenu(id: string, e: MouseEvent) {
+ e.stopPropagation();
+ if (openMenuId === id) {
+ openMenuId = null;
+ return;
+ }
+ const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
+ const right = window.innerWidth - r.right;
+ menuPos =
+ r.top > 96
+ ? { bottom: window.innerHeight - r.top + 4, right }
+ : { top: r.bottom + 4, right };
+ openMenuId = id;
+ }
+
// Distinct authors of loaded messages — power the @ autocomplete context.
const contextPubkeys = $derived([...new Set(messages.map((m) => m.pubkey))]);
@@ -111,6 +147,7 @@
function onListScroll() {
if (!listEl) return;
+ if (openMenuId) openMenuId = null;
const distance =
listEl.scrollHeight - listEl.scrollTop - listEl.clientHeight;
userScrolledUp = distance > 100;
@@ -139,8 +176,15 @@
function closeMenu() {
openMenuId = null;
}
+ function onKey(e: KeyboardEvent) {
+ if (e.key === "Escape") openMenuId = null;
+ }
document.addEventListener("click", closeMenu);
- return () => document.removeEventListener("click", closeMenu);
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("click", closeMenu);
+ document.removeEventListener("keydown", onKey);
+ };
});
@@ -188,7 +232,7 @@
No messages yet.
{:else}
-
+
{#each messages as msg (msg.id)}
{@const author = resolveAuthor(msg.pubkey)}
{@const parent = msg.replyToId ? getChatMessage(msg.replyToId) : null}
@@ -208,10 +252,60 @@
{author.name[0].toUpperCase()}
{/if}
-
{author.name}
-
{formatTime(msg.createdAt)}
+
{author.name}
+
+
+
+ {#if openMenuId === msg.id && menuPos}
+
+
+ {#if canModerate}
+
+ {/if}
+
+ {/if}
+
+
{formatTime(msg.createdAt)}
+
{#if msg.replyToId}
@@ -229,43 +323,6 @@
-
-
-
- {#if openMenuId === msg.id}
-
-
-
- {/if}
-
-
{/each}
diff --git a/src/lib/components/DeleteModal.svelte b/src/lib/components/DeleteModal.svelte
new file mode 100644
index 0000000..47cb14f
--- /dev/null
+++ b/src/lib/components/DeleteModal.svelte
@@ -0,0 +1,96 @@
+
+
+
+
+{#if deleteState.open && deleteState.target}
+
+
+
+
+ Delete {deleteState.target.label}?
+
+
+ This asks the relay to remove the {deleteState.target.label} for everyone.
+ It can't be undone.
+
+
+ {#if deleteState.error}
+
+ {deleteState.error}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+{/if}
diff --git a/src/lib/components/Toast.svelte b/src/lib/components/Toast.svelte
new file mode 100644
index 0000000..190970c
--- /dev/null
+++ b/src/lib/components/Toast.svelte
@@ -0,0 +1,27 @@
+
+
+{#if message}
+
+
+ {message}
+
+
+
+{/if}
diff --git a/src/lib/draft.svelte.ts b/src/lib/draft.svelte.ts
index b4fb1ff..978fab7 100644
--- a/src/lib/draft.svelte.ts
+++ b/src/lib/draft.svelte.ts
@@ -14,19 +14,37 @@ let publishing = $state(false);
let publishError = $state(null);
export const draftState = {
- get modalOpen() { return modalOpen; },
- get iconized() { return iconized; },
- get title() { return title; },
- set title(v: string) { title = v; },
- get labels() { return labels; },
- get content() { return content; },
- set content(v: string) { content = v; },
- get publishing() { return publishing; },
- get publishError() { return publishError; },
+ get modalOpen() {
+ return modalOpen;
+ },
+ get iconized() {
+ return iconized;
+ },
+ get title() {
+ return title;
+ },
+ set title(v: string) {
+ title = v;
+ },
+ get labels() {
+ return labels;
+ },
+ get content() {
+ return content;
+ },
+ set content(v: string) {
+ content = v;
+ },
+ get publishing() {
+ return publishing;
+ },
+ get publishError() {
+ return publishError;
+ },
get hasDraft() {
- return title.trim().length > 0 ||
- content.trim().length > 0 ||
- labels.length > 0;
+ return (
+ title.trim().length > 0 || content.trim().length > 0 || labels.length > 0
+ );
},
};
@@ -71,7 +89,10 @@ export function removeLabel(l: string) {
labels = labels.filter((x) => x !== l);
}
-export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }> {
+export async function publishDraft(): Promise<{
+ ok: boolean;
+ threadId?: string;
+}> {
if (!auth.signer) {
publishError = "Not logged in";
return { ok: false };
@@ -93,7 +114,9 @@ export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }
let threadId: string | undefined;
const ownPubkey = auth.user?.pubkey;
- const mentionPubkeys = extractMentionPubkeys(c).filter((pk) => pk !== ownPubkey);
+ const mentionPubkeys = extractMentionPubkeys(c).filter(
+ (pk) => pk !== ownPubkey,
+ );
const hints = await buildPTagHints(mentionPubkeys);
try {
@@ -118,7 +141,10 @@ export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }
const pool = new SimplePool();
try {
const timeout = new Promise((_, reject) =>
- setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
+ setTimeout(
+ () => reject(new Error("Relay did not respond in time")),
+ 8000,
+ ),
);
await Promise.race([
Promise.all(pool.publish([RELAY_URL], event)),
diff --git a/src/lib/join.svelte.ts b/src/lib/join.svelte.ts
index 2ea13fb..42b3b59 100644
--- a/src/lib/join.svelte.ts
+++ b/src/lib/join.svelte.ts
@@ -128,7 +128,10 @@ async function publishJoinRequest(groupId: string, code?: string) {
const pool = new SimplePool();
try {
const timeout = new Promise((_, reject) =>
- setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
+ setTimeout(
+ () => reject(new Error("Relay did not respond in time")),
+ 8000,
+ ),
);
await Promise.race([
Promise.all(pool.publish([RELAY_URL], event)),
diff --git a/src/lib/mentions.ts b/src/lib/mentions.ts
index d20e7e8..11d7bb5 100644
--- a/src/lib/mentions.ts
+++ b/src/lib/mentions.ts
@@ -31,11 +31,11 @@ export function extractQuotedEvents(content: string): QuotedEvent[] {
try {
const decoded = nip19.decode(m[1]);
if (decoded.type === "note") {
- if (!seen.has(decoded.data)) seen.set(decoded.data, { id: decoded.data });
+ if (!seen.has(decoded.data))
+ seen.set(decoded.data, { id: decoded.data });
} else if (decoded.type === "nevent") {
const { id, relays, author } = decoded.data;
- if (!seen.has(id))
- seen.set(id, { id, relay: relays?.[0], author });
+ if (!seen.has(id)) seen.set(id, { id, relay: relays?.[0], author });
}
} catch {
// Invalid bech32, skip
@@ -44,7 +44,9 @@ export function extractQuotedEvents(content: string): QuotedEvent[] {
return [...seen.values()];
}
-export async function relayHintFor(pubkey: string): Promise {
+export async function relayHintFor(
+ pubkey: string,
+): Promise {
try {
const TIMEOUT = Symbol();
const result = await Promise.race([
@@ -60,7 +62,9 @@ export async function relayHintFor(pubkey: string): Promise
}
}
-export async function buildPTagHints(pubkeys: Iterable): Promise