diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42b02f52..55242092 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
* Switch back to indexeddb to fix memory and performance
* Add pay invoice functionality
+* Add room membership management and bans
# 1.5.3
diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 00000000..01420260
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,32 @@
+# Flotilla - AI Assistant Context
+
+## Project Overview
+
+Flotilla is a Discord-like Nostr client based on the concept of "relays as groups". It's built with SvelteKit, TypeScript, and Capacitor for cross-platform support (web, Android, iOS).
+
+On boot, please run `tree -I assets src` to get an idea of the project structure.
+
+## Key Dependencies
+
+`@welshman/*` libraries contain the majority of nostr-related functionality.
+`@app/core/*` contains additional app-specific data stores and commands.
+
+When creating an import statement, first identify what functionality you need. Search the codebase for components with similar functionality, and imitate their imports.
+
+## Dependency Graph (Acyclic)
+
+The project follows a strict dependency hierarchy:
+1. **External libraries** (bottom layer)
+2. **`lib/`** - Only depends on external libraries
+3. **`app/core/`** and **`app/util/`** - Can depend on `lib` only
+4. **`app/components/`** - Can depend on anything in `app` or `lib`
+5. **`routes/`** - Can depend on anything (top layer)
+
+**Import Ordering Convention:** Always sort imports by dependency level:
+1. Third-party libraries first
+2. Then `lib` imports
+3. Then `app` imports
+
+## Development Conventions
+
+When creating components related to a given space or room, parameterize them only with the entity's identifier (i.e., `url` and `h`). Only pass additional props if they can't be derived from the identifiers. For example, a room's `members` should be derived inside the child component, not passed in by the parent.
diff --git a/src/app/components/Chat.svelte b/src/app/components/Chat.svelte
index 99ef8500..1dbc4ee4 100644
--- a/src/app/components/Chat.svelte
+++ b/src/app/components/Chat.svelte
@@ -43,7 +43,7 @@
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
- import ProfileList from "@app/components/ProfileList.svelte"
+ import ChatMembers from "@app/components/ChatMembers.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChatCompose.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
@@ -72,7 +72,9 @@
const missingInboxes = $derived(pubkeys.filter(pk => !$inboxRelaySelectionsByPubkey.has(pk)))
const showMembers = () =>
- pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`})
+ others.length === 1
+ ? pushModal(ProfileDetail, {pubkey: others[0]})
+ : pushModal(ChatMembers, {pubkeys: others})
const replyTo = (event: TrustedEvent) => {
parent = event
@@ -208,19 +210,17 @@
{#snippet title()}
-
+
{#if others.length === 0}
{:else if others.length === 1}
- {@const pubkey = others[0]}
- {@const onClick = () => pushModal(ProfileDetail, {pubkey})}
-
-
-
-
+
{:else}
@@ -235,26 +235,20 @@
{/if}
- {#if others.length > 2}
- Show all members
- {/if}
{/if}
-
+
{/snippet}
{#snippet action()}
-
- {#if remove($pubkey, missingInboxes).length > 0}
- {@const count = remove($pubkey, missingInboxes).length}
- {@const label = count > 1 ? "inboxes are" : "inbox is"}
-
-
- {count}
-
- {/if}
-
+ {#if remove($pubkey, missingInboxes).length > 0}
+ {@const count = remove($pubkey, missingInboxes).length}
+ {@const label = count > 1 ? "inboxes are" : "inbox is"}
+
+
+ {count}
+
+ {/if}
{/snippet}
diff --git a/src/app/components/ChatMembers.svelte b/src/app/components/ChatMembers.svelte
new file mode 100644
index 00000000..dc1a76d8
--- /dev/null
+++ b/src/app/components/ChatMembers.svelte
@@ -0,0 +1,25 @@
+
+
+
+
+ {#snippet title()}
+ People in this conversation
+ {/snippet}
+
+ {#each pubkeys as pubkey (pubkey)}
+
+ {/each}
+
history.back()}>Got it
+
diff --git a/src/app/components/ProfileLatest.svelte b/src/app/components/ProfileLatest.svelte
index 6a17bab5..9d2afad0 100644
--- a/src/app/components/ProfileLatest.svelte
+++ b/src/app/components/ProfileLatest.svelte
@@ -2,8 +2,6 @@
import type {Snippet} from "svelte"
import {load} from "@welshman/net"
import {NOTE} from "@welshman/util"
- import {fly} from "@lib/transition"
- import Spinner from "@lib/components/Spinner.svelte"
import NoteItem from "@app/components/NoteItem.svelte"
interface Props {
@@ -24,16 +22,16 @@
{#await events}
-
-
+
+
{:then events}
{#each events as event (event.id)}
-
-
-
+
{:else}
- {@render fallback?.()}
+
+ {@render fallback?.()}
+
{/each}
{/await}
diff --git a/src/app/components/RoomDetail.svelte b/src/app/components/RoomDetail.svelte
index 0402c51e..fce499d5 100644
--- a/src/app/components/RoomDetail.svelte
+++ b/src/app/components/RoomDetail.svelte
@@ -18,7 +18,7 @@
import Confirm from "@lib/components/Confirm.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
- import ProfileList from "@app/components/ProfileList.svelte"
+ import RoomMembers from "@app/components/RoomMembers.svelte"
import RoomEdit from "@app/components/RoomEdit.svelte"
import RoomName from "@app/components/RoomName.svelte"
import RoomImage from "@app/components/RoomImage.svelte"
@@ -67,12 +67,7 @@
const leave = () => handleLoading(leaveRoom)
- const showMembers = () =>
- pushModal(ProfileList, {
- title: "Members",
- subtitle: `of ${$room?.name || h}`,
- pubkeys: $members,
- })
+ const showMembers = () => pushModal(RoomMembers, {url, h})
const startDelete = () =>
pushModal(Confirm, {
@@ -139,12 +134,12 @@
{$room.about}
{/if}
{#if $members.length > 0}
-
+
{/if}
diff --git a/src/app/components/RoomMembers.svelte b/src/app/components/RoomMembers.svelte
new file mode 100644
index 00000000..8140b9a4
--- /dev/null
+++ b/src/app/components/RoomMembers.svelte
@@ -0,0 +1,33 @@
+
+
+
+
+ {#snippet title()}
+ Members
+ {/snippet}
+ {#snippet info()}
+ of
+ {/snippet}
+
+ {#each $members as pubkey (pubkey)}
+
+ {/each}
+
history.back()}>Got it
+
diff --git a/src/app/components/SpaceDetail.svelte b/src/app/components/SpaceDetail.svelte
index 18d2d736..9bbbab72 100644
--- a/src/app/components/SpaceDetail.svelte
+++ b/src/app/components/SpaceDetail.svelte
@@ -34,21 +34,29 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
{displayRelayUrl(url)}
+
-
-
-
-
-
{displayRelayUrl(url)}
-
+ {#if $userIsAdmin}
+
+
+ Edit
+
+ {/if}
{#if $relay?.terms_of_service || $relay?.privacy_policy}
@@ -83,18 +91,10 @@
{/if}
- {#if $userIsAdmin}
-
-
-
- Go back
-
-
-
- Edit Space
-
-
- {:else}
-
Got it
- {/if}
+
+
+
+ Go back
+
+
diff --git a/src/app/components/SpaceMembers.svelte b/src/app/components/SpaceMembers.svelte
new file mode 100644
index 00000000..f34b93c5
--- /dev/null
+++ b/src/app/components/SpaceMembers.svelte
@@ -0,0 +1,123 @@
+
+
+
+
+
Members
+
of {displayRelayUrl(url)}
+
+ {#if $userIsAdmin}
+
+
+
+ Add members
+
+ {#if $bans.length > 0}
+
+ Banned users ({$bans.length})
+
+ {/if}
+
+ {/if}
+ {#each $members as pubkey (pubkey)}
+
+
+
+
+
toggleMenu(pubkey)}>
+
+
+ {#if menuPubkey === pubkey}
+
+
+
+ banMember(pubkey)}>
+
+ Ban User
+
+
+
+
+ {/if}
+
+
+
+ {/each}
+
+
+
+ Go back
+
+
+
diff --git a/src/app/components/SpaceMembersAdd.svelte b/src/app/components/SpaceMembersAdd.svelte
new file mode 100644
index 00000000..d76af0cd
--- /dev/null
+++ b/src/app/components/SpaceMembersAdd.svelte
@@ -0,0 +1,78 @@
+
+
+
+
+ {#snippet title()}
+ Add Members
+ {/snippet}
+ {#snippet info()}
+ to {displayRelayUrl(url)}
+ {/snippet}
+
+
+ {#snippet label()}
+ Search for Members
+ {/snippet}
+ {#snippet input()}
+
+ {/snippet}
+
+
+
+
+ Go back
+
+
+ Save changes
+
+
+
diff --git a/src/app/components/SpaceMembersBanned.svelte b/src/app/components/SpaceMembersBanned.svelte
new file mode 100644
index 00000000..3ba22899
--- /dev/null
+++ b/src/app/components/SpaceMembersBanned.svelte
@@ -0,0 +1,95 @@
+
+
+
+
+ {#snippet title()}
+ Banned users
+ {/snippet}
+ {#snippet info()}
+ on {displayRelayUrl(url)}
+ {/snippet}
+
+ {#each $bans as { pubkey, reason } (pubkey)}
+
+
+
+
+
toggleMenu(pubkey)}>
+
+
+ {#if menuPubkey === pubkey}
+
+
+
+ restoreMember(pubkey)}>
+
+ Restore User
+
+
+
+
+ {/if}
+
+
+
+ {/each}
+
+
+
+ Got it
+
+
+
diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte
index 6c238b4f..af96e4f7 100644
--- a/src/app/components/SpaceMenu.svelte
+++ b/src/app/components/SpaceMenu.svelte
@@ -32,7 +32,7 @@
import SpaceExit from "@app/components/SpaceExit.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import RelayName from "@app/components/RelayName.svelte"
- import ProfileList from "@app/components/ProfileList.svelte"
+ import SpaceMembers from "@app/components/SpaceMembers.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte"
import Alerts from "@app/components/Alerts.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte"
@@ -83,12 +83,7 @@
const showDetail = () => pushModal(SpaceDetail, {url}, {replaceState})
- const showMembers = () =>
- pushModal(
- ProfileList,
- {url, pubkeys: $members, title: `Members of`, subtitle: displayRelayUrl(url)},
- {replaceState},
- )
+ const showMembers = () => pushModal(SpaceMembers, {url}, {replaceState})
const canCreateRoom = deriveUserCanCreateRoom(url)
diff --git a/src/app/core/state.ts b/src/app/core/state.ts
index 073fd3e2..bf5b18f0 100644
--- a/src/app/core/state.ts
+++ b/src/app/core/state.ts
@@ -752,6 +752,24 @@ export const deriveSpaceMembers = (url: string) =>
},
)
+export type BannedPubkeyItem = {
+ pubkey: string
+ reason: string
+}
+
+export const spaceBannedPubkeyItems = new Map
()
+
+export const deriveSpaceBannedPubkeyItems = (url: string) => {
+ const store = writable(spaceBannedPubkeyItems.get(url) || [])
+
+ manageRelay(url, {method: ManagementMethod.ListBannedPubkeys, params: []}).then(res => {
+ spaceBannedPubkeyItems.set(url, res.result)
+ store.set(res.result)
+ })
+
+ return store
+}
+
export const deriveRoomMembers = (url: string, h: string) =>
derived(
deriveEventsForUrl(url, [
@@ -806,7 +824,7 @@ export enum MembershipStatus {
Granted,
}
-export const deriveUserIsSpaceAdmin = (url: string) => {
+export const deriveUserIsSpaceAdmin = memoize((url: string) => {
const store = writable(false)
manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}).then(res =>
@@ -814,7 +832,7 @@ export const deriveUserIsSpaceAdmin = (url: string) => {
)
return store
-}
+})
export const deriveUserSpaceMembershipStatus = (url: string) =>
derived(
diff --git a/src/lib/components/Dialog.svelte b/src/lib/components/Dialog.svelte
index 2a0dfd18..86a638e0 100644
--- a/src/lib/components/Dialog.svelte
+++ b/src/lib/components/Dialog.svelte
@@ -16,7 +16,7 @@
cx(
"bg-alt text-base-content overflow-auto text-base-content shadow-md",
"px-4 py-6 bottom-0 left-0 right-0 top-20 rounded-t-box absolute",
- "sm:p-6 sm:max-h-[90vh] sm:w-[520px] sm:rounded-box sm:relative sm:top-0",
+ "sm:p-6 sm:max-h-[90vh] sm:w-[520px] sm:rounded-box sm:relative sm:top-0 sm:relative",
),
)
@@ -28,7 +28,7 @@
transition:fade={{duration: 300}}
onclick={onClose}>
-
diff --git a/src/lib/indexeddb.ts b/src/lib/indexeddb.ts
index 2c434276..8c0c077c 100644
--- a/src/lib/indexeddb.ts
+++ b/src/lib/indexeddb.ts
@@ -27,15 +27,14 @@ export type IDBOptions = {
export class IDB {
idbp: Maybe>
- ready: Maybe>
unsubscribers: Maybe
status = IDBStatus.Initial
constructor(readonly options: IDBOptions) {}
- init(adapters: IDBAdapters) {
- if (this.status !== IDBStatus.Initial) {
- throw new Error(`Database re-initialized while ${this.status}`)
+ async init(adapters: IDBAdapters) {
+ if (this.idbp) {
+ await this.close()
}
this.status = IDBStatus.Opening
@@ -62,7 +61,7 @@ export class IDB {
blocking() {},
})
- this.ready = this.idbp.then(async idbp => {
+ return this.idbp.then(async idbp => {
window.addEventListener("beforeunload", () => idbp.close())
this.unsubscribers = await Promise.all(adapters.map(({name, init}) => init(this.table(name))))
@@ -132,13 +131,9 @@ export class IDB {
await idbp.close()
- // Allow the caller to call reset and re-init immediately
- if (this.status === IDBStatus.Closing) {
- this.idbp = undefined
- this.ready = undefined
- this.unsubscribers = undefined
- this.status = IDBStatus.Closed
- }
+ this.idbp = undefined
+ this.unsubscribers = undefined
+ this.status = IDBStatus.Closed
})
clear = async () => {
@@ -147,14 +142,6 @@ export class IDB {
blocked() {},
})
}
-
- reset = () => {
- if (![IDBStatus.Closing, IDBStatus.Closed].includes(this.status)) {
- throw new Error("Database reset when not closed")
- }
-
- this.status = IDBStatus.Initial
- }
}
export class IDBTable {
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 7dad0e24..f61b91e7 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -113,12 +113,6 @@
// Wait until data storage is initialized before syncing other stuff
await db.init(storage.adapters)
- // Close DB and restart when we're done
- unsubscribers.push(() => {
- db.close()
- db.reset()
- })
-
// Add our extra policies now that we're set up
defaultSocketPolicies.push(...policies)