Move from topic to room using tilde

This commit is contained in:
Jon Staab
2024-09-23 10:28:33 -07:00
parent 88000d02ba
commit 4c9b7da586
12 changed files with 75 additions and 67 deletions
+7
View File
@@ -1,3 +1,10 @@
# Flotilla # Flotilla
A discord-like nostr client. WIP. A discord-like nostr client. WIP.
# Notes
- Privacy, migrations, and content replication
- Allow relays to strip signatures based on auth'd user
- Federated relays/admins can get signatures
- Other users have to opt-in to using the relay in trusted mode
+6 -5
View File
@@ -14,7 +14,7 @@ import {
loadMutes, loadMutes,
followsByPubkey, followsByPubkey,
} from "@welshman/app" } from "@welshman/app"
import {MEMBERSHIPS, INDEXER_RELAYS} from "@app/state" import {ROOM, MEMBERSHIPS, INDEXER_RELAYS} from "@app/state"
// Utils // Utils
@@ -95,9 +95,9 @@ export const updateList = async (kind: number, modifyTags: ModifyTags) => {
export const addSpaceMembership = (url: string) => export const addSpaceMembership = (url: string) =>
updateList(MEMBERSHIPS, (tags: string[][]) => uniqBy(t => t.join(""), [...tags, ["r", url]])) updateList(MEMBERSHIPS, (tags: string[][]) => uniqBy(t => t.join(""), [...tags, ["r", url]]))
export const addRoomMembership = (url: string, topic: string) => export const addRoomMembership = (url: string, room: string) =>
updateList(MEMBERSHIPS, (tags: string[][]) => updateList(MEMBERSHIPS, (tags: string[][]) =>
uniqBy(t => t.join(""), [...tags, ["t", topic, url]]), uniqBy(t => t.join(""), [...tags, [ROOM, room, url]]),
) )
export const removeSpaceMembership = (url: string) => export const removeSpaceMembership = (url: string) =>
@@ -105,5 +105,6 @@ export const removeSpaceMembership = (url: string) =>
tags.filter(t => !equals(["r", url], t) && t[2] !== url), tags.filter(t => !equals(["r", url], t) && t[2] !== url),
) )
export const removeRoomMembership = (url: string, topic: string) => export const removeRoomMembership = (url: string, room: string) =>
updateList(MEMBERSHIPS, (tags: string[][]) => tags.filter(t => !equals(["t", topic, url], t))) updateList(MEMBERSHIPS, (tags: string[][]) => tags.filter(t => !equals([ROOM, room, url], t)))
+3 -4
View File
@@ -11,10 +11,10 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import {makeMention, makeIMeta} from "@app/commands" import {makeMention, makeIMeta} from "@app/commands"
import {getChatEditorOptions, addFile} from "@app/editor" import {getChatEditorOptions, addFile} from "@app/editor"
import {MESSAGE} from "@app/state" import {ROOM, MESSAGE, GENERAL} from "@app/state"
export let url export let url
export let topic = "" export let room = GENERAL
const uploading = writable(false) const uploading = writable(false)
@@ -22,7 +22,6 @@
const sendMessage = () => { const sendMessage = () => {
const json = $editor.getJSON() const json = $editor.getJSON()
const topicTags = topic ? [["t", topic]] : []
const mentionTags = findNodes(NProfileExtension.name, json).map(m => const mentionTags = findNodes(NProfileExtension.name, json).map(m =>
makeMention(m.attrs!.pubkey, m.attrs!.relays), makeMention(m.attrs!.pubkey, m.attrs!.relays),
) )
@@ -32,7 +31,7 @@
const event = createEvent(MESSAGE, { const event = createEvent(MESSAGE, {
content: $editor.getText(), content: $editor.getText(),
tags: [["-"], ...topicTags, ...mentionTags, ...imetaTags], tags: [[ROOM, room], ...mentionTags, ...imetaTags],
}) })
publishThunk(makeThunk({event, relays: [url]})) publishThunk(makeThunk({event, relays: [url]}))
+2 -3
View File
@@ -16,7 +16,6 @@
} from "@welshman/app" } from "@welshman/app"
import type {PublishStatusData} from "@welshman/app" import type {PublishStatusData} from "@welshman/app"
import { import {
GROUP_REPLY,
REACTION, REACTION,
ZAP_RESPONSE, ZAP_RESPONSE,
displayRelayUrl, displayRelayUrl,
@@ -27,7 +26,7 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
import {deriveEvent, displayReaction} from "@app/state" import {REPLY, deriveEvent, displayReaction} from "@app/state"
import {getChatViewOptions} from "@app/editor" import {getChatViewOptions} from "@app/editor"
export let event: TrustedEvent export let event: TrustedEvent
@@ -85,7 +84,7 @@
</script> </script>
<div in:fly class="group relative flex flex-col gap-1 p-2 transition-colors hover:bg-base-300"> <div in:fly class="group relative flex flex-col gap-1 p-2 transition-colors hover:bg-base-300">
{#if event.kind === GROUP_REPLY} {#if event.kind === REPLY}
<div class="flex items-center gap-1 pl-12 text-xs"> <div class="flex items-center gap-1 pl-12 text-xs">
<Icon icon="arrow-right" /> <Icon icon="arrow-right" />
<Avatar src={$parentProfile?.picture} size={4} /> <Avatar src={$parentProfile?.picture} size={4} />
-1
View File
@@ -52,7 +52,6 @@
const event = createEvent(kind, { const event = createEvent(kind, {
content: $editor.getText(), content: $editor.getText(),
tags: [ tags: [
["-"],
["d", randomId()], ["d", randomId()],
["title", title], ["title", title],
["location", location], ["location", location],
+1 -1
View File
@@ -51,7 +51,7 @@
class="!h-10 !w-10 border border-solid border-base-300" class="!h-10 !w-10 border border-solid border-base-300"
size={7} /> size={7} />
</PrimaryNavItem> </PrimaryNavItem>
{#each $userMembership?.topicsByUrl.keys() || [] as url (url)} {#each $userMembership?.roomsByUrl.keys() || [] as url (url)}
<PrimaryNavItem title={displayRelayUrl(url)} href={makeSpacePath(url)}> <PrimaryNavItem title={displayRelayUrl(url)} href={makeSpacePath(url)}>
<SpaceAvatar {url} /> <SpaceAvatar {url} />
</PrimaryNavItem> </PrimaryNavItem>
+5 -5
View File
@@ -13,9 +13,9 @@
const back = () => history.back() const back = () => history.back()
const tryCreate = async () => { const tryCreate = async () => {
await addRoomMembership(url, topic) await addRoomMembership(url, room)
goto(makeSpacePath(url, topic)) goto(makeSpacePath(url, room))
} }
const create = async () => { const create = async () => {
@@ -28,7 +28,7 @@
} }
} }
let topic = "" let room = ""
let loading = false let loading = false
</script> </script>
@@ -41,7 +41,7 @@
<p slot="label">Room Name</p> <p slot="label">Room Name</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input"> <label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="hashtag" /> <Icon icon="hashtag" />
<input bind:value={topic} class="grow" type="text" /> <input bind:value={room} class="grow" type="text" />
</label> </label>
</Field> </Field>
<div class="flex flex-row items-center justify-between gap-4"> <div class="flex flex-row items-center justify-between gap-4">
@@ -49,7 +49,7 @@
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={!topic || loading}> <Button type="submit" class="btn btn-primary" disabled={!room || loading}>
<Spinner {loading}>Create Room</Spinner> <Spinner {loading}>Create Room</Spinner>
<Icon icon="alt-arrow-right" /> <Icon icon="alt-arrow-right" />
</Button> </Button>
+1 -1
View File
@@ -15,7 +15,7 @@ export const makeSpacePath = (url: string, extra = "") => {
export const getPrimaryNavItem = ($page: Page) => $page.route?.id?.split("/")[1] export const getPrimaryNavItem = ($page: Page) => $page.route?.id?.split("/")[1]
export const getPrimaryNavItemIndex = ($page: Page) => { export const getPrimaryNavItemIndex = ($page: Page) => {
const urls = Array.from(userMembership.get()?.topicsByUrl.keys() || []) const urls = Array.from(userMembership.get()?.roomsByUrl.keys() || [])
switch (getPrimaryNavItem($page)) { switch (getPrimaryNavItem($page)) {
case "discover": case "discover":
+28 -25
View File
@@ -1,7 +1,7 @@
import {nip19} from "nostr-tools" import {nip19} from "nostr-tools"
import {get, derived} from "svelte/store" import {get, derived} from "svelte/store"
import type {Maybe} from "@welshman/lib" import type {Maybe} from "@welshman/lib"
import {setContext, max, pushToMapKey, nthEq} from "@welshman/lib" import {setContext, nth, max, pushToMapKey, nthEq} from "@welshman/lib"
import { import {
getIdFilters, getIdFilters,
RELAYS, RELAYS,
@@ -10,7 +10,6 @@ import {
EVENT_DATE, EVENT_DATE,
EVENT_TIME, EVENT_TIME,
getRelayTagValues, getRelayTagValues,
getTopicTagValues,
isShareableRelayUrl, isShareableRelayUrl,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
@@ -31,9 +30,13 @@ import {
import type {SubscribeRequestWithHandlers} from "@welshman/net" import type {SubscribeRequestWithHandlers} from "@welshman/net"
import {deriveEvents, deriveEventsMapped, withGetter} from "@welshman/store" import {deriveEvents, deriveEventsMapped, withGetter} from "@welshman/store"
export const ROOM = "~"
export const GENERAL = "general"
export const MESSAGE = 209 export const MESSAGE = 209
export const REPLY = 210 export const REPLY = 1111
export const MEMBERSHIPS = 10209 export const MEMBERSHIPS = 10209
@@ -75,7 +78,7 @@ export const deriveEvent = (idOrAddress: string, hints: string[] = []) => {
// Membership // Membership
export type Membership = { export type Membership = {
topicsByUrl: Map<string, string[]> roomsByUrl: Map<string, string[]>
event?: TrustedEvent event?: TrustedEvent
} }
@@ -84,17 +87,17 @@ export type PublishedMembership = Omit<Membership, "event"> & {
} }
export const readMembership = (event: TrustedEvent): PublishedMembership => { export const readMembership = (event: TrustedEvent): PublishedMembership => {
const topicsByUrl = new Map<string, string[]>() const roomsByUrl = new Map<string, string[]>()
for (const tag of event.tags.filter(nthEq(0, "r"))) { for (const tag of event.tags.filter(nthEq(0, "r"))) {
topicsByUrl.set(tag[1], []) roomsByUrl.set(tag[1], [])
} }
for (const tag of event.tags.filter(nthEq(0, "t"))) { for (const tag of event.tags.filter(nthEq(0, "t"))) {
pushToMapKey(topicsByUrl, tag[2], tag[1]) pushToMapKey(roomsByUrl, tag[2], tag[1])
} }
return {event, topicsByUrl} return {event, roomsByUrl}
} }
export const memberships = deriveEventsMapped<PublishedMembership>(repository, { export const memberships = deriveEventsMapped<PublishedMembership>(repository, {
@@ -121,16 +124,16 @@ export const {
// Messages // Messages
export type Message = { export type Message = {
topic: string room: string
event: TrustedEvent event: TrustedEvent
} }
export const readMessage = (event: TrustedEvent): Maybe<Message> => { export const readMessage = (event: TrustedEvent): Maybe<Message> => {
const topics = getTopicTagValues(event.tags) const rooms = event.tags.filter(nthEq(0, ROOM)).map(nth(1))
if (topics.length > 1) return undefined if (rooms.length > 1) return undefined
return {topic: topics[0] || "", event} return {room: rooms[0] || "", event}
} }
export const messages = deriveEventsMapped<Message>(repository, { export const messages = deriveEventsMapped<Message>(repository, {
@@ -144,11 +147,11 @@ export const messages = deriveEventsMapped<Message>(repository, {
export type Chat = { export type Chat = {
id: string id: string
url: string url: string
topic: string room: string
messages: Message[] messages: Message[]
} }
export const makeChatId = (url: string, topic: string) => `${url}'${topic}` export const makeChatId = (url: string, room: string) => `${url}'${room}`
export const splitChatId = (id: string) => id.split("'") export const splitChatId = (id: string) => id.split("'")
@@ -157,16 +160,16 @@ export const chats = derived([trackerStore, messages], ([$tracker, $messages]) =
for (const message of $messages) { for (const message of $messages) {
for (const url of $tracker.getRelays(message.event.id)) { for (const url of $tracker.getRelays(message.event.id)) {
const chatId = makeChatId(url, message.topic) const chatId = makeChatId(url, message.room)
pushToMapKey(messagesByChatId, chatId, message) pushToMapKey(messagesByChatId, chatId, message)
} }
} }
return Array.from(messagesByChatId.entries()).map(([id, messages]) => { return Array.from(messagesByChatId.entries()).map(([id, messages]) => {
const [url, topic] = splitChatId(id) const [url, room] = splitChatId(id)
return {id, url, topic, messages} return {id, url, room, messages}
}) })
}) })
@@ -179,12 +182,12 @@ export const {
store: chats, store: chats,
getKey: chat => chat.id, getKey: chat => chat.id,
load: (id: string, request: Partial<SubscribeRequestWithHandlers> = {}) => { load: (id: string, request: Partial<SubscribeRequestWithHandlers> = {}) => {
const [url, topic] = splitChatId(id) const [url, room] = splitChatId(id)
const chat = get(chatsById).get(id) const chat = get(chatsById).get(id)
const timestamps = chat?.messages.map(m => m.event.created_at) || [] const timestamps = chat?.messages.map(m => m.event.created_at) || []
const since = Math.max(0, max(timestamps) - 3600) const since = Math.max(0, max(timestamps) - 3600)
return load({...request, relays: [url], filters: [{"#t": [topic], since}]}) return load({...request, relays: [url], filters: [{'#~': [room], since}]})
}, },
}) })
@@ -204,18 +207,18 @@ export const eventsByUrl = derived([trackerStore, events], ([$tracker, $events])
return eventsByUrl return eventsByUrl
}) })
// Topics // Rooms
export const topicsByUrl = derived(chats, $chats => { export const roomsByUrl = derived(chats, $chats => {
const $topicsByUrl = new Map<string, string[]>() const $roomsByUrl = new Map<string, string[]>()
for (const chat of $chats) { for (const chat of $chats) {
if (chat.topic) { if (chat.room) {
pushToMapKey($topicsByUrl, chat.url, chat.topic) pushToMapKey($roomsByUrl, chat.url, chat.room)
} }
} }
return $topicsByUrl return $roomsByUrl
}) })
// User stuff // User stuff
+1 -1
View File
@@ -56,7 +56,7 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if $userMembership?.topicsByUrl.has(relay.url)} {#if $userMembership?.roomsByUrl.has(relay.url)}
<div class="center absolute flex w-full"> <div class="center absolute flex w-full">
<div <div
class="tooltip relative left-8 top-[38px] h-5 w-5 rounded-full bg-primary" class="tooltip relative left-8 top-[38px] h-5 w-5 rounded-full bg-primary"
+11 -11
View File
@@ -16,7 +16,7 @@
import SpaceExit from "@app/components/SpaceExit.svelte" import SpaceExit from "@app/components/SpaceExit.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte" import SpaceJoin from "@app/components/SpaceJoin.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte" import RoomCreate from "@app/components/RoomCreate.svelte"
import {userMembership, topicsByUrl, decodeNRelay, MESSAGE, REPLY} from "@app/state" import {userMembership, roomsByUrl, decodeNRelay, GENERAL, MESSAGE, REPLY} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
@@ -48,8 +48,8 @@
let showMenu = false let showMenu = false
$: url = decodeNRelay($page.params.nrelay) $: url = decodeNRelay($page.params.nrelay)
$: rooms = sort($userMembership?.topicsByUrl?.get(url) || []) $: rooms = sort($userMembership?.roomsByUrl?.get(url) || [])
$: otherRooms = ($topicsByUrl.get(url) || []).filter(t => !rooms.includes(t)) $: otherRooms = ($roomsByUrl.get(url) || []).filter(room => !rooms.concat(GENERAL).includes(room))
onMount(() => { onMount(() => {
const kinds = [MESSAGE, REPLY, EVENT_DATE, EVENT_TIME, CLASSIFIED] const kinds = [MESSAGE, REPLY, EVENT_DATE, EVENT_TIME, CLASSIFIED]
@@ -72,7 +72,7 @@
<ul <ul
transition:fly transition:fly
class="menu absolute z-popover mt-2 w-full rounded-box bg-base-100 p-2 shadow-xl"> class="menu absolute z-popover mt-2 w-full rounded-box bg-base-100 p-2 shadow-xl">
{#if $userMembership?.topicsByUrl.has(url)} {#if $userMembership?.roomsByUrl.has(url)}
<li class="text-error"> <li class="text-error">
<Button on:click={leaveSpace}> <Button on:click={leaveSpace}>
<Icon icon="exit" /> <Icon icon="exit" />
@@ -113,11 +113,11 @@
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader> <SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
</div> </div>
{/if} {/if}
{#each rooms as topic, i (topic)} {#each rooms as room, i (room)}
<div transition:slide={{delay: getDelay()}}> <div transition:slide={{delay: getDelay()}}>
<SecondaryNavItem href={makeSpacePath(url, topic)}> <SecondaryNavItem href={makeSpacePath(url, room)}>
<Icon icon="hashtag" /> <Icon icon="hashtag" />
{topic} {room}
</SecondaryNavItem> </SecondaryNavItem>
</div> </div>
{/each} {/each}
@@ -133,11 +133,11 @@
</SecondaryNavHeader> </SecondaryNavHeader>
</div> </div>
{/if} {/if}
{#each otherRooms as topic, i (topic)} {#each otherRooms as room, i (room)}
<div transition:slide={{delay: getDelay()}}> <div transition:slide={{delay: getDelay()}}>
<SecondaryNavItem href={makeSpacePath(url, topic)}> <SecondaryNavItem href={makeSpacePath(url, room)}>
<Icon icon="hashtag" /> <Icon icon="hashtag" />
{topic} {room}
</SecondaryNavItem> </SecondaryNavItem>
</div> </div>
{/each} {/each}
@@ -150,7 +150,7 @@
</SecondaryNavSection> </SecondaryNavSection>
</SecondaryNav> </SecondaryNav>
<Page> <Page>
{#key $page.params.topic} {#key $page.params.room}
<slot /> <slot />
{/key} {/key}
</Page> </Page>
@@ -18,19 +18,19 @@
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte" import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChatCompose.svelte" import ChatCompose from "@app/components/ChatCompose.svelte"
import {userMembership, decodeNRelay, makeChatId, deriveChat} from "@app/state" import {userMembership, decodeNRelay, makeChatId, deriveChat, GENERAL} from "@app/state"
import {addRoomMembership, removeRoomMembership} from "@app/commands" import {addRoomMembership, removeRoomMembership} from "@app/commands"
const {nrelay, topic = ""} = $page.params const {nrelay, room = GENERAL} = $page.params
const url = decodeNRelay(nrelay) const url = decodeNRelay(nrelay)
const chat = deriveChat(makeChatId(url, topic)) const chat = deriveChat(makeChatId(url, room))
const assertEvent = (e: any) => e as TrustedEvent const assertEvent = (e: any) => e as TrustedEvent
let loading = true let loading = true
let elements: Element[] = [] let elements: Element[] = []
$: membership = $userMembership?.topicsByUrl.get(url) || [] $: membership = $userMembership?.roomsByUrl.get(url) || []
$: { $: {
elements = [] elements = []
@@ -71,16 +71,16 @@
class="flex min-h-12 items-center justify-between gap-4 rounded-xl bg-base-100 px-4 shadow-xl"> class="flex min-h-12 items-center justify-between gap-4 rounded-xl bg-base-100 px-4 shadow-xl">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Icon icon="hashtag" /> <Icon icon="hashtag" />
<strong>{topic || "General"}</strong> <strong>{room || "General"}</strong>
</div> </div>
{#if topic} {#if room}
{#if membership.includes(topic)} {#if membership.includes(room)}
<Button class="btn btn-neutral btn-sm" on:click={() => removeRoomMembership(url, topic)}> <Button class="btn btn-neutral btn-sm" on:click={() => removeRoomMembership(url, room)}>
<Icon icon="arrows-a-logout-2" /> <Icon icon="arrows-a-logout-2" />
Leave Room Leave Room
</Button> </Button>
{:else} {:else}
<Button class="btn btn-neutral btn-sm" on:click={() => addRoomMembership(url, topic)}> <Button class="btn btn-neutral btn-sm" on:click={() => addRoomMembership(url, room)}>
<Icon icon="login-2" /> <Icon icon="login-2" />
Join Room Join Room
</Button> </Button>
@@ -106,5 +106,5 @@
</Spinner> </Spinner>
</p> </p>
</div> </div>
<ChatCompose {url} {topic} /> <ChatCompose {url} {room} />
</div> </div>