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()} -
+ +
+ + +
{:else}
@@ -235,26 +235,20 @@ {/if}

- {#if others.length > 2} - - {/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} + +
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} -
- Members: - -
+
+ {/if} + 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} + + {/if}
{#if $relay?.terms_of_service || $relay?.privacy_policy} @@ -83,18 +91,10 @@
{/if}
- {#if $userIsAdmin} - - - - - {:else} - - {/if} + + +
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} +
+ + {#if $bans.length > 0} + + {/if} +
+ {/if} + {#each $members as pubkey (pubkey)} +
+
+
+ +
+
+ + {#if menuPubkey === pubkey} + + + + {/if} +
+
+
+ {/each} + + + +
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} +
+ + + + +
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)} +
+
+
+ +
+
+ + {#if menuPubkey === pubkey} + + + + {/if} +
+
+
+ {/each} + + + +
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}> -
+
{@render children?.()}
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)