forked from coracle/flotilla
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc88030943 | |||
| 84acad4a20 | |||
| 64a62a72d1 | |||
| e0511edc4d | |||
| 981c8fd706 | |||
| 45ade602b5 | |||
| ef8a8682cd | |||
| 112ac4b6d5 | |||
| 3a26d2cb0b | |||
| a678bf42f1 | |||
| dc314a1d1b | |||
| 3af56f6bb1 | |||
| a996664e6c |
@@ -170,6 +170,15 @@ src/
|
||||
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates
|
||||
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
||||
|
||||
**Human-First Simplicity (Jon Staab Style):**
|
||||
|
||||
- Prefer direct, readable code over layered abstractions.
|
||||
- Do not add indirection (extra helpers, wrappers, stores, or derived state) unless it removes real repeated complexity.
|
||||
- Reuse existing Welshman and Flotilla primitives before introducing new utilities or dependencies.
|
||||
- Favor linear control flow and explicit naming over clever patterns.
|
||||
- Remove defensive checks that do not apply in this runtime model.
|
||||
- When two approaches work, pick the one that feels more human and easier to maintain.
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Component
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env zsh
|
||||
|
||||
onchange src -ik -- npx svelte-kit sync &
|
||||
|
||||
onchange src -ik -- bash -c 'unbuffer npx svelte-check --tsconfig ./tsconfig.json | less -R' &
|
||||
|
||||
wait
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"build": "./build.sh",
|
||||
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"check:watch": "./check.sh",
|
||||
"lint": "prettier --check src && eslint src",
|
||||
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
||||
"format:all": "prettier --write src",
|
||||
|
||||
+2
-2
@@ -274,7 +274,7 @@
|
||||
.input-editor,
|
||||
.chat-editor,
|
||||
.note-editor {
|
||||
@apply -m-1 min-h-12 p-1 text-sm;
|
||||
@apply -m-1 p-1;
|
||||
}
|
||||
|
||||
.tiptap {
|
||||
@@ -300,7 +300,7 @@
|
||||
}
|
||||
|
||||
.tiptap {
|
||||
@apply max-h-[350px] overflow-y-auto p-2 px-4;
|
||||
@apply max-h-[350px] min-h-10 overflow-y-auto p-2 px-4;
|
||||
}
|
||||
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {onMount, onDestroy} from "svelte"
|
||||
import {displayUrl} from "@welshman/lib"
|
||||
import {displayUrl, once} from "@welshman/lib"
|
||||
import {
|
||||
getTags,
|
||||
getBlob,
|
||||
@@ -27,7 +27,7 @@
|
||||
const nonce = getTagValue("decryption-nonce", meta)
|
||||
const algorithm = getTagValue("encryption-algorithm", meta)
|
||||
|
||||
const onError = async () => {
|
||||
const onError = once(async () => {
|
||||
// If the image failed to load, try authenticating
|
||||
if (hash && $signer) {
|
||||
const server = new URL(url).origin
|
||||
@@ -43,7 +43,7 @@
|
||||
} else {
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let hasError = $state(false)
|
||||
let src = $state("")
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<Icon icon={Reply} />
|
||||
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
|
||||
</div>
|
||||
<div class="btn btn-neutral btn-xs relative hidden rounded-full sm:flex">
|
||||
<div class="btn btn-neutral btn-xs relative rounded-full">
|
||||
{#if gt(lastActive, $checked)}
|
||||
<div class="h-2 w-2 rounded-full bg-primary"></div>
|
||||
{/if}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts">
|
||||
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
|
||||
|
||||
type Props = {
|
||||
urls: string[]
|
||||
}
|
||||
|
||||
const {urls}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="column menu gap-2">
|
||||
{#each urls as url (url)}
|
||||
<MenuSpacesItem {url} />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||
import Server from "@assets/icons/server.svg?dataurl"
|
||||
import Moon from "@assets/icons/moon.svg?dataurl"
|
||||
@@ -19,8 +20,8 @@
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {theme} from "@app/util/theme"
|
||||
|
||||
const back = () => history.back()
|
||||
const logout = () => pushModal(LogOut)
|
||||
|
||||
const toggleTheme = () => theme.set($theme === "dark" ? "light" : "dark")
|
||||
</script>
|
||||
|
||||
@@ -123,6 +124,10 @@
|
||||
<Button onclick={logout} class="btn btn-neutral">
|
||||
<Icon icon={Exit} /> Log Out
|
||||
</Button>
|
||||
<Button class="btn btn-link w-full md:hidden" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</script>
|
||||
|
||||
<Link replaceState href={path}>
|
||||
<CardButton class="btn-neutral shadow-md bg-alt">
|
||||
<CardButton class="btn-neutral shadow-md bg-alt rounded-box border-none">
|
||||
{#snippet icon()}
|
||||
<RelayIcon {url} size={12} class="rounded-full" />
|
||||
{/snippet}
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
import cx from "classnames"
|
||||
import type {Snippet} from "svelte"
|
||||
import {formatTimestamp} from "@welshman/lib"
|
||||
import {getListTags, getPubkeyTagValues} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {userMuteList} from "@welshman/app"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Profile from "@app/components/Profile.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
import {goToEvent} from "@app/util/routes"
|
||||
import {isEventMuted} from "@app/core/state"
|
||||
|
||||
const {
|
||||
event,
|
||||
@@ -32,7 +31,7 @@
|
||||
muted = false
|
||||
}
|
||||
|
||||
let muted = $state(getPubkeyTagValues(getListTags($userMuteList)).includes(event.pubkey))
|
||||
let muted = $state($isEventMuted(event))
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 shadow-md {restProps.class}">
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||
import ChatEnable from "@app/components/ChatEnable.svelte"
|
||||
import MenuOtherSpaces from "@app/components/MenuOtherSpaces.svelte"
|
||||
import MenuSettings from "@app/components/MenuSettings.svelte"
|
||||
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
||||
import {userSpaceUrls, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/core/state"
|
||||
@@ -28,8 +27,6 @@
|
||||
|
||||
const {children}: Props = $props()
|
||||
|
||||
const showOtherSpacesMenu = () => pushModal(MenuOtherSpaces, {urls: secondarySpaceUrls})
|
||||
|
||||
const showSettingsMenu = () => pushModal(MenuSettings)
|
||||
|
||||
const openChat = () => ($shouldUnwrap ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
|
||||
@@ -60,15 +57,13 @@
|
||||
{#each primarySpaceUrls as url (url)}
|
||||
<PrimaryNavItemSpace {url} />
|
||||
{/each}
|
||||
{#if secondarySpaceUrls.length > 0}
|
||||
<PrimaryNavItem
|
||||
title="Other Spaces"
|
||||
class="tooltip-right"
|
||||
onclick={showOtherSpacesMenu}
|
||||
notification={otherSpaceNotifications}>
|
||||
<ImageIcon alt="Other Spaces" src={Widget} size={8} />
|
||||
</PrimaryNavItem>
|
||||
{/if}
|
||||
<PrimaryNavItem
|
||||
href="/spaces"
|
||||
title="All Spaces"
|
||||
class="tooltip-right"
|
||||
notification={otherSpaceNotifications}>
|
||||
<ImageIcon alt="All Spaces" src={Widget} size={8} />
|
||||
</PrimaryNavItem>
|
||||
<PrimaryNavItem title="Add a Space" href="/discover" class="tooltip-right">
|
||||
<ImageIcon alt="Add a Space" src={Compass} size={8} />
|
||||
</PrimaryNavItem>
|
||||
|
||||
@@ -152,18 +152,18 @@
|
||||
transition:fly
|
||||
class="bg-alt menu absolute right-0 z-popover w-48 gap-1 rounded-box p-2 shadow-md">
|
||||
{#if $userIsAdmin}
|
||||
<li>
|
||||
<Button class="text-error" onclick={startDelete}>
|
||||
<Icon icon={TrashBin2} />
|
||||
Delete Room
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={startEdit}>
|
||||
<Icon icon={Pen} />
|
||||
Edit Room
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button class="text-error" onclick={startDelete}>
|
||||
<Icon icon={TrashBin2} />
|
||||
Delete Room
|
||||
</Button>
|
||||
</li>
|
||||
{:else if $membershipStatus === MembershipStatus.Initial}
|
||||
<li>
|
||||
<Button disabled={loading} onclick={join}>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
makeList,
|
||||
addToListPublicly,
|
||||
removeFromListByPredicate,
|
||||
updateList,
|
||||
getTag,
|
||||
getListTags,
|
||||
getRelayTagValues,
|
||||
@@ -148,6 +149,20 @@ export const removeSpaceMembership = async (url: string) => {
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const setSpaceMembershipOrder = async (urls: string[]) => {
|
||||
const list = get(userGroupList) || makeList({kind: ROOMS})
|
||||
const orderedUrls = uniq(urls.map(normalizeRelayUrl))
|
||||
const relayTags = list.publicTags.filter(t => t[0] === "r")
|
||||
const otherPublicTags = list.publicTags.filter(t => t[0] !== "r")
|
||||
const relayTagByUrl = new Map(relayTags.map(t => [normalizeRelayUrl(t[1]), t]))
|
||||
const orderedRelayTags = orderedUrls.map(url => relayTagByUrl.get(url) || ["r", url])
|
||||
const publicTags = [...orderedRelayTags, ...otherPublicTags]
|
||||
const event = await updateList(list, {publicTags}).reconcile(nip44EncryptToSelf)
|
||||
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const addRoomMembership = async (url: string, h: string) => {
|
||||
const list = get(userGroupList) || makeList({kind: ROOMS})
|
||||
const newTags = [
|
||||
|
||||
+131
-1
@@ -27,6 +27,7 @@ import {
|
||||
randomId,
|
||||
tryCatch,
|
||||
fromPairs,
|
||||
groupBy,
|
||||
remove,
|
||||
} from "@welshman/lib"
|
||||
import type {Override} from "@welshman/lib"
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
makeDeriveEvent,
|
||||
makeLoadItem,
|
||||
makeDeriveItem,
|
||||
deriveItems,
|
||||
deriveItemsByKey,
|
||||
deriveDeduplicated,
|
||||
deriveEventsById,
|
||||
@@ -58,6 +60,8 @@ import {
|
||||
deriveEventsDesc,
|
||||
} from "@welshman/store"
|
||||
import {
|
||||
FEED,
|
||||
FEEDS,
|
||||
APP_DATA,
|
||||
CLIENT_AUTH,
|
||||
COMMENT,
|
||||
@@ -90,6 +94,8 @@ import {
|
||||
ZAP_GOAL,
|
||||
ZAP_REQUEST,
|
||||
ZAP_RESPONSE,
|
||||
REPOST,
|
||||
GENERIC_REPOST,
|
||||
asDecryptedEvent,
|
||||
getGroupTags,
|
||||
getListTags,
|
||||
@@ -104,14 +110,29 @@ import {
|
||||
makeRoomMeta,
|
||||
ManagementMethod,
|
||||
sortEventsDesc,
|
||||
getAddress,
|
||||
Address,
|
||||
getIdFilters,
|
||||
getEventTagValues,
|
||||
getAddressTagValues,
|
||||
getParentIds,
|
||||
getParentAddrs,
|
||||
} from "@welshman/util"
|
||||
import type {
|
||||
TrustedEvent,
|
||||
RelayProfile,
|
||||
PublishedList,
|
||||
PublishedRoomMeta,
|
||||
List,
|
||||
Filter,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent, RelayProfile, PublishedRoomMeta, List, Filter} from "@welshman/util"
|
||||
import {routerContext, Router} from "@welshman/router"
|
||||
import {
|
||||
pubkey,
|
||||
repository,
|
||||
tracker,
|
||||
createSearch,
|
||||
userMuteList,
|
||||
userFollowList,
|
||||
ensurePlaintext,
|
||||
makeOutboxLoader,
|
||||
@@ -121,7 +142,9 @@ import {
|
||||
makeUserLoader,
|
||||
manageRelay,
|
||||
displayProfileByPubkey,
|
||||
getProfile,
|
||||
} from "@welshman/app"
|
||||
import {readFeed} from "@lib/feeds"
|
||||
|
||||
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
|
||||
|
||||
@@ -285,6 +308,8 @@ export const makeCommentFilter = (kinds: number[], extra: Filter = {}) => ({
|
||||
...extra,
|
||||
})
|
||||
|
||||
export const REPOST_KINDS = [REPOST, GENERIC_REPOST]
|
||||
|
||||
export const REACTION_KINDS = [REPORT, DELETE, REACTION]
|
||||
|
||||
if (ENABLE_ZAPS) {
|
||||
@@ -915,6 +940,111 @@ export const deriveUserCanCreateRoom = (url: string) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Feeds
|
||||
|
||||
export const feedsByAddress = deriveItemsByKey({
|
||||
repository,
|
||||
getKey: feed => getAddress(feed.event),
|
||||
filters: [{kinds: [FEED]}],
|
||||
eventToItem: readFeed,
|
||||
})
|
||||
|
||||
export const getFeedsByAddress = getter(feedsByAddress)
|
||||
|
||||
export const feeds = deriveItems(feedsByAddress)
|
||||
|
||||
export const getFeeds = getter(feeds)
|
||||
|
||||
export const getFeed = (address: string) => getFeedsByAddress().get(address)
|
||||
|
||||
export const fetchFeed = (address: string) => {
|
||||
const {pubkey} = Address.from(address)
|
||||
|
||||
return load({
|
||||
relays: Router.get().FromPubkey(pubkey).getUrls(),
|
||||
filters: getIdFilters([address]),
|
||||
})
|
||||
}
|
||||
|
||||
export const loadFeed = makeLoadItem(fetchFeed, getFeed)
|
||||
|
||||
export const deriveFeed = makeDeriveItem(feedsByAddress, loadFeed)
|
||||
|
||||
// Feeds by pubkey
|
||||
|
||||
export const feedsByPubkey = derived(feeds, $feeds => groupBy(f => f.event.pubkey, $feeds))
|
||||
|
||||
export const getFeedsByPubkey = getter(feedsByPubkey)
|
||||
|
||||
export const getFeedsForPubkey = (pubkey: string) => getFeedsByPubkey().get(pubkey)
|
||||
|
||||
export const loadFeedsForPubkey = makeLoadItem(makeOutboxLoader(FEED), getFeedsForPubkey)
|
||||
|
||||
export const userFeeds = makeUserData(feedsByPubkey, loadFeedsForPubkey)
|
||||
|
||||
export const loadUserFeeds = makeUserLoader(loadFeedsForPubkey)
|
||||
|
||||
// Feed favorites
|
||||
|
||||
export const feedFavoritesByPubkey = deriveItemsByKey<PublishedList>({
|
||||
repository,
|
||||
getKey: list => list.event.pubkey,
|
||||
filters: [{kinds: [FEEDS]}],
|
||||
eventToItem: async event =>
|
||||
readList(
|
||||
asDecryptedEvent(event, {
|
||||
content: await ensurePlaintext(event),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const getFeedFavoritesByPubkey = getter(feedFavoritesByPubkey)
|
||||
|
||||
export const getFeedFavorites = (pubkey: string) => getFeedFavoritesByPubkey().get(pubkey)
|
||||
|
||||
export const loadFeedFavorites = makeLoadItem(makeOutboxLoader(FEEDS), getFeedFavorites)
|
||||
|
||||
export const userFeedFavorites = makeUserData(feedFavoritesByPubkey, loadFeedFavorites)
|
||||
|
||||
export const loadUserFeedFavorites = makeUserLoader(loadFeedFavorites)
|
||||
|
||||
// Mutes
|
||||
|
||||
export const isEventMuted = withGetter(
|
||||
derived(userMuteList, $userMuteList => {
|
||||
const pubkey = $userMuteList?.event.pubkey
|
||||
const tags = getListTags($userMuteList)
|
||||
const mutedEvents = new Set(getEventTagValues(tags))
|
||||
const mutedPubkeys = new Set(getPubkeyTagValues(tags))
|
||||
const mutedAddresses = new Set(getAddressTagValues(tags))
|
||||
const mutedTopics = new Set(getTagValues("t", tags))
|
||||
const mutedWords = getTagValues("word", tags)
|
||||
const regex =
|
||||
mutedWords.length > 0
|
||||
? new RegExp(`\\b(${mutedWords.map(w => w.toLowerCase().trim()).join("|")})\\b`)
|
||||
: null
|
||||
|
||||
return (e: TrustedEvent) => {
|
||||
if (!pubkey) return false
|
||||
if (pubkey === e.pubkey) return false
|
||||
if (mutedPubkeys.has(e.pubkey)) return true
|
||||
if (mutedEvents.has(e.id)) return true
|
||||
if (mutedAddresses.has(getAddress(e))) return true
|
||||
if (getParentIds(e).some(id => mutedEvents.has(id))) return true
|
||||
if (getParentAddrs(e).some(address => mutedAddresses.has(address))) return true
|
||||
if (getTagValues("t", e.tags).some(t => mutedTopics.has(t))) return true
|
||||
|
||||
if (regex) {
|
||||
if (e.content?.toLowerCase().match(regex)) return true
|
||||
if (displayProfileByPubkey(e.pubkey).toLowerCase().match(regex)) return true
|
||||
if (tryCatch(() => getProfile(e.pubkey)?.nip05?.match(regex))) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Other utils
|
||||
|
||||
export const encodeRelay = (url: string) =>
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
getSpaceUrlsFromGroupList,
|
||||
getSpaceRoomsFromGroupList,
|
||||
makeCommentFilter,
|
||||
loadFeedsForPubkey,
|
||||
} from "@app/core/state"
|
||||
import {hasBlossomSupport} from "@app/core/commands"
|
||||
|
||||
@@ -200,6 +201,7 @@ const syncUserData = () => {
|
||||
loadMuteList($userRelayList.event.pubkey)
|
||||
loadProfile($userRelayList.event.pubkey)
|
||||
loadSettings($userRelayList.event.pubkey)
|
||||
loadFeedsForPubkey($userRelayList.event.pubkey)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import {append, identity, uniq} from "@welshman/lib"
|
||||
import {repository} from "@welshman/app"
|
||||
import {displayPubkey, getTagValue} from "@welshman/util"
|
||||
import {PLATFORM_NAME, decodeRelay, getRoom, makeRoomId, splitChatId} from "@app/core/state"
|
||||
|
||||
const FALLBACK_APP_NAME = "Flotilla"
|
||||
|
||||
const staticTitles = new Map<string, string>([
|
||||
["/", "Redirecting"],
|
||||
["/home", "Home"],
|
||||
["/discover", "Discover Spaces"],
|
||||
["/spaces", "Your Spaces"],
|
||||
["/spaces/create", "Create a Space"],
|
||||
["/spaces/[relay]", "Space"],
|
||||
["/spaces/[relay]/chat", "Space Chat"],
|
||||
["/spaces/[relay]/recent", "Recent Activity"],
|
||||
["/spaces/[relay]/threads", "Threads"],
|
||||
["/spaces/[relay]/classifieds", "Classifieds"],
|
||||
["/spaces/[relay]/calendar", "Calendar"],
|
||||
["/spaces/[relay]/goals", "Goals"],
|
||||
["/chat", "Messages"],
|
||||
["/join", "Join Space"],
|
||||
["/people", "Find People"],
|
||||
["/settings/about", "About"],
|
||||
["/settings/profile", "Profile Settings"],
|
||||
["/settings/content", "Content Settings"],
|
||||
["/settings/privacy", "Privacy Settings"],
|
||||
["/settings/relays", "Relay Settings"],
|
||||
["/settings/alerts", "Alert Settings"],
|
||||
["/settings/wallet", "Wallet Settings"],
|
||||
["/[bech32]", "Opening Link"],
|
||||
])
|
||||
|
||||
const eventRoutes = new Set([
|
||||
"/spaces/[relay]/threads/[id]",
|
||||
"/spaces/[relay]/goals/[id]",
|
||||
"/spaces/[relay]/calendar/[address]",
|
||||
"/spaces/[relay]/classifieds/[address]",
|
||||
])
|
||||
|
||||
type RouteParams = Record<string, string | undefined>
|
||||
|
||||
type TitlePage = {
|
||||
route: {id: string | null}
|
||||
params: RouteParams
|
||||
}
|
||||
|
||||
type PageTitleContext = {
|
||||
page: TitlePage
|
||||
pubkey: string | undefined
|
||||
}
|
||||
|
||||
const getRoomTitle = (params: RouteParams) => {
|
||||
const relay = params.relay
|
||||
const h = params.h
|
||||
|
||||
if (!relay || !h) {
|
||||
return "Room"
|
||||
}
|
||||
|
||||
const url = decodeRelay(relay)
|
||||
|
||||
return getRoom(makeRoomId(url, h))?.name || "Room"
|
||||
}
|
||||
|
||||
const getEventForTitle = (routeId: string, params: RouteParams) => {
|
||||
if (!eventRoutes.has(routeId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const eventId = params.id || params.address
|
||||
|
||||
if (!eventId) {
|
||||
return
|
||||
}
|
||||
|
||||
return repository.getEvent(eventId)
|
||||
}
|
||||
|
||||
const getChatTitle = (chatId: string | undefined, pubkey: string | undefined) => {
|
||||
if (!chatId) {
|
||||
return "Chat"
|
||||
}
|
||||
|
||||
const chatPeers = pubkey ? uniq(append(pubkey, splitChatId(chatId))) : splitChatId(chatId)
|
||||
const others = pubkey ? chatPeers.filter(pk => pk !== pubkey) : chatPeers
|
||||
|
||||
if (others.length === 1) {
|
||||
return `Chat with ${displayPubkey(others[0])}`
|
||||
}
|
||||
|
||||
if (others.length > 1) {
|
||||
return `Group chat (${others.length})`
|
||||
}
|
||||
|
||||
return "Chat"
|
||||
}
|
||||
|
||||
export const makeTitle = (...parts: Array<string | undefined>) =>
|
||||
parts
|
||||
.map(part => part?.trim() || "")
|
||||
.filter(identity)
|
||||
.join(" · ") ||
|
||||
PLATFORM_NAME ||
|
||||
FALLBACK_APP_NAME
|
||||
|
||||
export const getPageTitle = ({page, pubkey}: PageTitleContext) => {
|
||||
const routeId = page.route.id || ""
|
||||
const staticTitle = staticTitles.get(routeId)
|
||||
|
||||
if (staticTitle) {
|
||||
return makeTitle(staticTitle)
|
||||
}
|
||||
|
||||
if (routeId === "/chat/[chat]") {
|
||||
return makeTitle(getChatTitle(page.params.chat, pubkey))
|
||||
}
|
||||
|
||||
if (routeId === "/spaces/[relay]/[h]") {
|
||||
return makeTitle(getRoomTitle(page.params))
|
||||
}
|
||||
|
||||
const event = getEventForTitle(routeId, page.params)
|
||||
|
||||
if (routeId === "/spaces/[relay]/threads/[id]") {
|
||||
return makeTitle(getTagValue("title", event?.tags || []) || "Thread")
|
||||
}
|
||||
|
||||
if (routeId === "/spaces/[relay]/calendar/[address]") {
|
||||
return makeTitle(getTagValue("title", event?.tags || []) || "Event")
|
||||
}
|
||||
|
||||
if (routeId === "/spaces/[relay]/classifieds/[address]") {
|
||||
return makeTitle(getTagValue("title", event?.tags || []) || "Listing")
|
||||
}
|
||||
|
||||
if (routeId === "/spaces/[relay]/goals/[id]") {
|
||||
return makeTitle(event?.content || getTagValue("summary", event?.tags || []) || "Goal")
|
||||
}
|
||||
|
||||
return makeTitle()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {fromPairs, parseJson, randomId} from "@welshman/lib"
|
||||
import {FEED, Address} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {
|
||||
makeIntersectionFeed,
|
||||
hasSubFeeds,
|
||||
isTagFeed,
|
||||
isAuthorFeed,
|
||||
isScopeFeed,
|
||||
} from "@welshman/feeds"
|
||||
import type {Feed as IFeed} from "@welshman/feeds"
|
||||
|
||||
export type Feed = {
|
||||
title: string
|
||||
identifier: string
|
||||
description: string
|
||||
definition: IFeed
|
||||
event?: TrustedEvent
|
||||
}
|
||||
|
||||
export type PublishedFeed = Omit<Feed, "event"> & {
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
export const normalizeFeedDefinition = (feed: IFeed) =>
|
||||
hasSubFeeds(feed) ? feed : makeIntersectionFeed(feed)
|
||||
|
||||
export const makeFeed = (feed: Partial<Feed> = {}): Feed => ({
|
||||
title: "",
|
||||
description: "",
|
||||
identifier: randomId(),
|
||||
definition: makeIntersectionFeed(),
|
||||
...feed,
|
||||
})
|
||||
|
||||
export const readFeed = (event: TrustedEvent) => {
|
||||
const {d: identifier, title = "", description = "", feed = ""} = fromPairs(event.tags)
|
||||
const definition = parseJson(feed) || makeIntersectionFeed()
|
||||
|
||||
return {title, identifier, description, definition, event} as PublishedFeed
|
||||
}
|
||||
|
||||
export const createFeed = ({identifier, definition, title, description}: Feed) => ({
|
||||
kind: FEED,
|
||||
content: "",
|
||||
tags: [
|
||||
["d", identifier],
|
||||
["alt", title],
|
||||
["title", title],
|
||||
["description", description],
|
||||
["feed", JSON.stringify(definition)],
|
||||
],
|
||||
})
|
||||
|
||||
export const editFeed = (feed: PublishedFeed) => ({
|
||||
kind: FEED,
|
||||
content: feed.event.content,
|
||||
tags: Object.entries({
|
||||
...fromPairs(feed.event.tags),
|
||||
title: feed.title,
|
||||
alt: feed.title,
|
||||
description: feed.description,
|
||||
feed: JSON.stringify(feed.definition),
|
||||
}),
|
||||
})
|
||||
|
||||
export const displayFeed = (feed?: Feed) => feed?.title || "[no name]"
|
||||
|
||||
export const isTopicFeed = (f: IFeed) => isTagFeed(f) && f[1] === "#t"
|
||||
|
||||
export const isMentionFeed = (f: IFeed) => isTagFeed(f) && f[1] === "#p"
|
||||
|
||||
export const isAddressFeed = (f: IFeed) => isTagFeed(f) && f[1] === "#a"
|
||||
|
||||
export const isContextFeed = (f: IFeed) =>
|
||||
isTagFeed(f) && f[1] === "#a" && f.slice(2).every(Address.isAddress)
|
||||
|
||||
export const isPeopleFeed = (f: IFeed) => isAuthorFeed(f) || isScopeFeed(f)
|
||||
@@ -7,6 +7,7 @@
|
||||
import {App, type URLOpenListenerEvent} from "@capacitor/app"
|
||||
import {dev} from "$app/environment"
|
||||
import {goto} from "$app/navigation"
|
||||
import {page} from "$app/stores"
|
||||
import {sync, throttled} from "@welshman/store"
|
||||
import {call} from "@welshman/lib"
|
||||
import {defaultSocketPolicies} from "@welshman/net"
|
||||
@@ -42,6 +43,7 @@
|
||||
import * as notifications from "@app/util/notifications"
|
||||
import * as storage from "@app/util/storage"
|
||||
import {syncKeyboard} from "@app/util/keyboard"
|
||||
import {getPageTitle} from "@app/util/title"
|
||||
import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte"
|
||||
|
||||
const {children} = $props()
|
||||
@@ -199,6 +201,10 @@
|
||||
App.removeAllListeners()
|
||||
unsubscribe.then(call)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
document.title = getPageTitle({page: $page, pubkey: $pubkey})
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
+66
-193
@@ -1,204 +1,77 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {derived, writable} from "svelte/store"
|
||||
import {batch, call, sortBy, uniqBy} from "@welshman/lib"
|
||||
import {
|
||||
NOTE,
|
||||
MESSAGE,
|
||||
THREAD,
|
||||
CLASSIFIED,
|
||||
ZAP_GOAL,
|
||||
EVENT_TIME,
|
||||
COMMENT,
|
||||
getTagValue,
|
||||
getTagValues,
|
||||
getIdAndAddress,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {
|
||||
makeKindFeed,
|
||||
makeRelayFeed,
|
||||
makeScopeFeed,
|
||||
makeIntersectionFeed,
|
||||
makeUnionFeed,
|
||||
Scope,
|
||||
} from "@welshman/feeds"
|
||||
import {repository, tracker, makeFeedController, loadUserFollowList} from "@welshman/app"
|
||||
import History from "@assets/icons/history.svg?dataurl"
|
||||
import {createScroller} from "@lib/html"
|
||||
import {goto} from "$app/navigation"
|
||||
import {shouldUnwrap} from "@welshman/app"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import Compass from "@assets/icons/compass.svg?dataurl"
|
||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import PageBar from "@lib/components/PageBar.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import NoteItem from "@app/components/NoteItem.svelte"
|
||||
import ThreadItem from "@app/components/ThreadItem.svelte"
|
||||
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
|
||||
import GoalItem from "@app/components/GoalItem.svelte"
|
||||
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
|
||||
import RecentConversation from "@app/components/RecentConversation.svelte"
|
||||
import {makeRoomId, userSpaceUrls, loadUserGroupList, CONTENT_KINDS} from "@app/core/state"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import CardButton from "@lib/components/CardButton.svelte"
|
||||
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
||||
import ChatEnable from "@app/components/ChatEnable.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {goToSpace} from "@app/util/routes"
|
||||
import {PLATFORM_NAME, PLATFORM_RELAYS} from "@app/core/state"
|
||||
|
||||
type Activity = {
|
||||
type: "message" | "content"
|
||||
event: TrustedEvent
|
||||
timestamp: number
|
||||
count: number
|
||||
url: string
|
||||
}
|
||||
const addSpace = () => pushModal(SpaceAdd)
|
||||
|
||||
const controller = new AbortController()
|
||||
const events = writable<TrustedEvent[]>([])
|
||||
const limit = writable(0)
|
||||
const openChat = () => ($shouldUnwrap ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
|
||||
|
||||
const recentActivity = derived([events, limit], ([$events, $limit]) => {
|
||||
const activity: Activity[] = []
|
||||
const activityByRoom = new Map<string, Activity>()
|
||||
const latestActivityByKey = new Map<string, number>()
|
||||
|
||||
for (const event of $events.slice(0, $limit)) {
|
||||
if (event.kind === MESSAGE) {
|
||||
const h = getTagValue("h", event.tags)
|
||||
|
||||
if (!h) continue
|
||||
|
||||
for (const url of tracker.getRelays(event.id)) {
|
||||
const id = makeRoomId(url, h)
|
||||
|
||||
const item = activityByRoom.get(id)
|
||||
|
||||
if (!item) {
|
||||
activityByRoom.set(id, {
|
||||
type: "message",
|
||||
event,
|
||||
timestamp: event.created_at,
|
||||
count: 1,
|
||||
url,
|
||||
})
|
||||
} else if (item.timestamp < event.created_at) {
|
||||
item.count++
|
||||
item.event = event
|
||||
item.timestamp = event.created_at
|
||||
}
|
||||
}
|
||||
} else if (event.kind === COMMENT) {
|
||||
for (const k of getTagValues(["E", "A"], event.tags)) {
|
||||
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
|
||||
}
|
||||
} else {
|
||||
for (const k of getIdAndAddress(event)) {
|
||||
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
|
||||
}
|
||||
}
|
||||
onMount(async () => {
|
||||
if (PLATFORM_RELAYS.length > 0) {
|
||||
goToSpace(PLATFORM_RELAYS[0])
|
||||
}
|
||||
|
||||
for (const item of activityByRoom.values()) {
|
||||
activity.push(item)
|
||||
}
|
||||
|
||||
for (const [address, timestamp] of latestActivityByKey.entries()) {
|
||||
const event = repository.getEvent(address)
|
||||
|
||||
if (event) {
|
||||
for (const url of tracker.getRelays(event.id)) {
|
||||
activity.push({type: "content", event, timestamp, url, count: 1})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sortBy(
|
||||
a => -a.timestamp,
|
||||
uniqBy(a => a.event.id, activity),
|
||||
)
|
||||
})
|
||||
|
||||
let loading = $state(true)
|
||||
let element: Element | undefined = $state()
|
||||
|
||||
onMount(() => {
|
||||
const promise = call(async () => {
|
||||
await Promise.all([loadUserGroupList(), loadUserFollowList()])
|
||||
|
||||
const ctrl = makeFeedController({
|
||||
useWindowing: true,
|
||||
signal: controller.signal,
|
||||
feed: makeUnionFeed(
|
||||
makeIntersectionFeed(
|
||||
makeRelayFeed(...$userSpaceUrls),
|
||||
makeKindFeed(COMMENT, ...CONTENT_KINDS),
|
||||
),
|
||||
makeIntersectionFeed(makeScopeFeed(Scope.Follows), makeKindFeed(NOTE)),
|
||||
),
|
||||
onEvent: batch(100, (evts: TrustedEvent[]) => {
|
||||
events.update($events => [...$events, ...evts])
|
||||
}),
|
||||
onExhausted: () => {
|
||||
loading = false
|
||||
},
|
||||
})
|
||||
|
||||
const scroller = createScroller({
|
||||
element: element!,
|
||||
delay: 800,
|
||||
threshold: 3000,
|
||||
onScroll: async () => {
|
||||
console.log("scroll")
|
||||
limit.update($limit => {
|
||||
if ($events.length - $limit < 50) {
|
||||
ctrl.load(50)
|
||||
}
|
||||
|
||||
return $limit + 10
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return () => {
|
||||
scroller.stop()
|
||||
controller.abort()
|
||||
}
|
||||
})
|
||||
|
||||
return () => promise.then(call)
|
||||
})
|
||||
</script>
|
||||
|
||||
<PageBar>
|
||||
{#snippet icon()}
|
||||
<div class="center">
|
||||
<Icon icon={History} />
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<strong>Recent Activity</strong>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<div class="row-2"></div>
|
||||
{/snippet}
|
||||
</PageBar>
|
||||
|
||||
<PageContent class="flex flex-col gap-2 p-2 pt-4" bind:element>
|
||||
{#each $recentActivity as { type, event, url, count } (event.id)}
|
||||
{#if type === "message"}
|
||||
<RecentConversation {url} {event} {count} />
|
||||
{:else if event.kind === THREAD}
|
||||
<ThreadItem {url} {event} />
|
||||
{:else if event.kind === CLASSIFIED}
|
||||
<ClassifiedItem {url} {event} />
|
||||
{:else if event.kind === ZAP_GOAL}
|
||||
<GoalItem {url} {event} />
|
||||
{:else if event.kind === EVENT_TIME}
|
||||
<CalendarEventItem {url} {event} />
|
||||
{:else}
|
||||
<NoteItem {url} {event} />
|
||||
{/if}
|
||||
{:else}
|
||||
{#if loading}
|
||||
<div class="flex justify-center items-center py-20">
|
||||
<span class="loading loading-spinner mr-3"></span>
|
||||
Loading recent activity...
|
||||
<div class="hero min-h-screen overflow-auto pb-8">
|
||||
<div class="hero-content">
|
||||
<div class="column content gap-4">
|
||||
<h1 class="text-center text-5xl">Welcome to</h1>
|
||||
<h1 class="mb-4 text-center text-5xl font-bold uppercase">{PLATFORM_NAME}</h1>
|
||||
<div class="col-3">
|
||||
<Button onclick={addSpace}>
|
||||
<CardButton class="btn-neutral">
|
||||
{#snippet icon()}
|
||||
<Icon icon={AddCircle} size={7} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Add a space</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Use an invite link, or create your own space.</div>
|
||||
{/snippet}
|
||||
</CardButton>
|
||||
</Button>
|
||||
<Link href="/discover">
|
||||
<CardButton class="btn-neutral">
|
||||
{#snippet icon()}
|
||||
<Icon icon={Compass} size={7} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Browse the network</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Find communities on the nostr network.</div>
|
||||
{/snippet}
|
||||
</CardButton>
|
||||
</Link>
|
||||
<Button onclick={openChat}>
|
||||
<CardButton class="btn-neutral">
|
||||
{#snippet icon()}
|
||||
<Icon icon={ChatRound} size={7} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Start a conversation</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Use nostr's encrypted group chats to stay in touch.</div>
|
||||
{/snippet}
|
||||
</CardButton>
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</PageContent>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {insertAt, removeAt} from "@welshman/lib"
|
||||
import SettingsMinimalistic from "@assets/icons/settings-minimalistic.svg?dataurl"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
@@ -9,9 +10,88 @@
|
||||
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
|
||||
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
||||
import {userSpaceUrls, loadUserGroupList, PLATFORM_RELAYS} from "@app/core/state"
|
||||
import {setSpaceMembershipOrder} from "@app/core/commands"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
|
||||
const addSpace = () => pushModal(SpaceAdd)
|
||||
|
||||
const reconcileUrls = (currentUrls: string[], nextUrls: string[]) => {
|
||||
const mergedUrls = currentUrls.filter(url => nextUrls.includes(url))
|
||||
|
||||
for (const url of nextUrls) {
|
||||
if (!mergedUrls.includes(url)) {
|
||||
mergedUrls.push(url)
|
||||
}
|
||||
}
|
||||
|
||||
return mergedUrls
|
||||
}
|
||||
|
||||
const isSameOrder = (a: string[], b: string[]) =>
|
||||
a.length === b.length && a.every((url, index) => url === b[index])
|
||||
|
||||
const reorderSpaceUrls = (targetUrl: string) => {
|
||||
if (!draggedUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceIndex = orderedSpaceUrls.indexOf(draggedUrl)
|
||||
const targetIndex = orderedSpaceUrls.indexOf(targetUrl)
|
||||
|
||||
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
orderedSpaceUrls = insertAt(
|
||||
targetIndex,
|
||||
orderedSpaceUrls[sourceIndex],
|
||||
removeAt(sourceIndex, orderedSpaceUrls),
|
||||
)
|
||||
}
|
||||
|
||||
const onDragStart = (e: DragEvent, url: string) => {
|
||||
draggedUrl = url
|
||||
dragStartOrder = [...orderedSpaceUrls]
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
e.dataTransfer.setData("text/plain", url)
|
||||
}
|
||||
}
|
||||
|
||||
const onDragOver = (e: DragEvent, targetUrl: string) => {
|
||||
e.preventDefault()
|
||||
reorderSpaceUrls(targetUrl)
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent, targetUrl: string) => {
|
||||
e.preventDefault()
|
||||
reorderSpaceUrls(targetUrl)
|
||||
draggedUrl = undefined
|
||||
|
||||
if (dragStartOrder && !isSameOrder(dragStartOrder, orderedSpaceUrls)) {
|
||||
void setSpaceMembershipOrder(orderedSpaceUrls).catch(console.error)
|
||||
}
|
||||
|
||||
dragStartOrder = undefined
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
draggedUrl = undefined
|
||||
dragStartOrder = undefined
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const nextUrls = reconcileUrls(orderedSpaceUrls, $userSpaceUrls)
|
||||
|
||||
if (!isSameOrder(nextUrls, orderedSpaceUrls)) {
|
||||
orderedSpaceUrls = nextUrls
|
||||
}
|
||||
})
|
||||
|
||||
let orderedSpaceUrls = $state<string[]>([])
|
||||
let draggedUrl = $state<string | undefined>()
|
||||
let dragStartOrder = $state<string[] | undefined>()
|
||||
</script>
|
||||
|
||||
<Page class="cw-full">
|
||||
@@ -43,8 +123,17 @@
|
||||
Loading your spaces...
|
||||
</div>
|
||||
{:then}
|
||||
{#each $userSpaceUrls as url (url)}
|
||||
<MenuSpacesItem {url} />
|
||||
{#each orderedSpaceUrls as url (url)}
|
||||
<div
|
||||
class:opacity-60={draggedUrl === url}
|
||||
draggable="true"
|
||||
role="listitem"
|
||||
ondragstart={e => onDragStart(e, url)}
|
||||
ondragover={e => onDragOver(e, url)}
|
||||
ondrop={e => onDrop(e, url)}
|
||||
ondragend={onDragEnd}>
|
||||
<MenuSpacesItem {url} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-8 items-center py-20">
|
||||
<p>You haven't added any spaces yet!</p>
|
||||
|
||||
Reference in New Issue
Block a user