Compare commits
13 Commits
1.7.2
..
28f691753b
| Author | SHA1 | Date | |
|---|---|---|---|
| 28f691753b | |||
| 89042f3e2a | |||
| 2c3e5b2130 | |||
| 1c8457a4bf | |||
| 8710043a02 | |||
| dc46b42cb6 | |||
| 2f1972e70a | |||
| c5fcf12165 | |||
| 61ed632579 | |||
| 86f4b75c52 | |||
| b26ab916d5 | |||
| c882198206 | |||
| 4aef27ffd5 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Env
|
# Env
|
||||||
.env
|
|
||||||
.env.local
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Flotilla is a Nostr "relays as groups" community chat client. It implements NIP-29 (relay-based groups) to create Discord-like spaces (servers) and rooms (channels).
|
||||||
|
|
||||||
|
Please visit our [issue tracker](https://gitea.coracle.social/coracle/flotilla/issues) to contribute. Any new issues should be opened without a milestone, label, or project and the project owners will triage them.
|
||||||
|
|
||||||
|
### Milestones
|
||||||
|
|
||||||
|
Milestones indicate how soon a given task should be tackled.
|
||||||
|
|
||||||
|
- [Current](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=1) issues are immediately actionable.
|
||||||
|
- [Next](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=2) means an issue is blocked.
|
||||||
|
- [Future](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=3) means we're deferring work until a later date.
|
||||||
|
|
||||||
|
### Labels
|
||||||
|
|
||||||
|
- [Design](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=15) issues need design work before being implemented. This might take the form of a high-quality mockup, wireframes, user flows, or just a couple notes about where things go, depending on the nature of the task.
|
||||||
|
- [Dev](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=16) issues are ready to be implemented. Most of the work will be related to architecting and writing code.
|
||||||
|
- [Easy](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=14) issues have no dependencies, and are scoped quite narrowly.
|
||||||
|
- [Priority](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=6) issues include bugs and urgent feature requests. These should get attention first if possible, although sometimes long-standing performance issues or subtle bugs might end up here for a while.
|
||||||
|
- [Ideas](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=13) are for things that aren't scoped out yet, or which need protocol work before getting designed or implemented.
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
Issues may or may not have a project. Projects are used to group issues thematically just for organization.
|
||||||
|
|
||||||
|
## Coding conventions
|
||||||
|
|
||||||
|
There are a few conventions that are helpful to know right out of the gate.
|
||||||
|
|
||||||
|
- Most nostr protocol functionality is implemented via the [welshman library](https://welshman.coracle.social/)
|
||||||
|
- Use Svelte 4 **stores** rather than runes for all state outside UI components
|
||||||
|
- Most global state flows through Welshman's `repository` (unidirectional)
|
||||||
|
- Query state using `deriveEventsMapped` or `deriveProfile` etc
|
||||||
|
- Events are published via `publishThunk`, which allows for optimistic UI updates during signing/pow generation.
|
||||||
|
- Components should have minimal props - e.g. instead of passing a whole `relay` through, pass its `url`.
|
||||||
|
- Use `AbortController` when possible instead of request ids
|
||||||
|
- Use `undefined` or optional properties instead of `null`
|
||||||
|
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
|
||||||
|
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
||||||
|
- When dynamically building classes, use `cx` from `classnames`.
|
||||||
|
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates.
|
||||||
|
|
||||||
|
## Contributing Workflow
|
||||||
|
|
||||||
|
To contribute, do the following:
|
||||||
|
|
||||||
|
- Find or create an issue and assign yourself (comment instead if you're not able to self-assign)
|
||||||
|
- If the issue is a design task, attach or link out to any mockups/wireframes/flowcharts
|
||||||
|
- Once a design task is completed, a maintainer will remove the `design` label and add the `dev` label
|
||||||
|
- If the issue is a development task, fork the repository and create a branch prefixed by the issue number, e.g. `105-deep-links`
|
||||||
|
- Before requesting a review, be sure to review any agent-generated code, run the pre-commit hooks, and test the changes.
|
||||||
|
- Open a PR and request a review. A maintainer will get back to you with requested changes, or will merge the PR.
|
||||||
|
- Keep your PR up-to-date by rebasing frequently on `dev`. Avoid force-pushing to `dev`.
|
||||||
|
- PRs are rebased, squashed, and merged to keep commit history simple.
|
||||||
|
- An issue may have multiple PRs. Once complete, it can be closed.
|
||||||
@@ -16,11 +16,13 @@ You can also optionally create an `.env.local` file and populate it with the fol
|
|||||||
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
||||||
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
|
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
|
||||||
|
|
||||||
|
These values **won't** be used for a built version. Instead, env variables should be provided to `build.sh` directly or to the built container.
|
||||||
|
|
||||||
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
|
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
See [CONTRIBUTING.md](AGENTS.md).
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
temp_env=$(declare -p -x)
|
temp_env=$(declare -p -x)
|
||||||
|
|
||||||
if [ -f .env.template ]; then
|
if [ -f .env ]; then
|
||||||
source .env.template
|
source .env
|
||||||
fi
|
|
||||||
if [ -f .env.local ]; then
|
|
||||||
source .env.local
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Avoid overwriting env vars provided directly
|
# Avoid overwriting env vars provided directly
|
||||||
|
|||||||
@@ -392,7 +392,7 @@
|
|||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.7.5;
|
MARKETING_VERSION = 1.7.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import dotenv from "dotenv"
|
|||||||
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
|
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
|
||||||
|
|
||||||
dotenv.config({path: ".env.local"})
|
dotenv.config({path: ".env.local"})
|
||||||
dotenv.config({path: ".env.template"})
|
dotenv.config({path: ".env"})
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
preset,
|
preset,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
|
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {notifications} from "@app/util/notifications"
|
import {notifications} from "@app/util/notifications"
|
||||||
import {goToChat} from "@app/util/routes"
|
import {goToChat, makeSpacePath} from "@app/util/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children?: Snippet
|
children?: Snippet
|
||||||
@@ -26,22 +26,20 @@
|
|||||||
|
|
||||||
const showSettingsMenu = () => pushModal(MenuSettings)
|
const showSettingsMenu = () => pushModal(MenuSettings)
|
||||||
|
|
||||||
const anySpaceNotifications = $derived($userSpaceUrls.some(p => $notifications.has(p)))
|
const anySpaceNotifications = $derived(
|
||||||
|
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
|
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
|
||||||
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
|
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
|
||||||
<PrimaryNavSpaces />
|
<PrimaryNavSpaces />
|
||||||
{#if PLATFORM_RELAYS.length > 0}
|
{#if PLATFORM_RELAYS.length > 0}
|
||||||
<Divider />
|
<Divider />
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
<div class="flex flex-col">
|
||||||
<PrimaryNavItem
|
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings">
|
||||||
title="Settings"
|
|
||||||
href="/settings/profile"
|
|
||||||
prefix="/settings"
|
|
||||||
class="tooltip-right">
|
|
||||||
{#if $userProfile?.picture}
|
{#if $userProfile?.picture}
|
||||||
<ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} />
|
<ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -51,11 +49,10 @@
|
|||||||
<PrimaryNavItem
|
<PrimaryNavItem
|
||||||
title="Messages"
|
title="Messages"
|
||||||
onclick={chatHandler}
|
onclick={chatHandler}
|
||||||
class="tooltip-right"
|
|
||||||
notification={$notifications.has("/chat")}>
|
notification={$notifications.has("/chat")}>
|
||||||
<ImageIcon alt="Messages" src={Letter} size={8} />
|
<ImageIcon alt="Messages" src={Letter} size={8} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
<PrimaryNavItem title="Search" href="/people" class="tooltip-right">
|
<PrimaryNavItem title="Search" href="/people">
|
||||||
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {deriveRelayDisplay} from "@welshman/app"
|
||||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||||
import {makeSpacePath, goToSpace} from "@app/util/routes"
|
import {makeSpacePath, goToSpace} from "@app/util/routes"
|
||||||
@@ -12,11 +12,13 @@
|
|||||||
const {url}: Props = $props()
|
const {url}: Props = $props()
|
||||||
|
|
||||||
const onClick = () => goToSpace(url)
|
const onClick = () => goToSpace(url)
|
||||||
|
|
||||||
|
const display = $derived(deriveRelayDisplay(url))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PrimaryNavItem
|
<PrimaryNavItem
|
||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
title={displayRelayUrl(url)}
|
title={$display}
|
||||||
class="tooltip-right"
|
class="tooltip-right"
|
||||||
notification={$notifications.has(makeSpacePath(url))}>
|
notification={$notifications.has(makeSpacePath(url))}>
|
||||||
<RelayIcon {url} size={10} class="rounded-full" />
|
<RelayIcon {url} size={10} class="rounded-full" />
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
{#each PLATFORM_RELAYS as url (url)}
|
{#each PLATFORM_RELAYS as url (url)}
|
||||||
<PrimaryNavItemSpace {url} />
|
<PrimaryNavItemSpace {url} />
|
||||||
{:else}
|
{:else}
|
||||||
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
|
<PrimaryNavItem title="Home" href="/home">
|
||||||
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
|
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -33,7 +33,6 @@
|
|||||||
<PrimaryNavItem
|
<PrimaryNavItem
|
||||||
href="/spaces"
|
href="/spaces"
|
||||||
title="All Spaces"
|
title="All Spaces"
|
||||||
class="tooltip-right"
|
|
||||||
prefix="no-highlight"
|
prefix="no-highlight"
|
||||||
notification={otherSpaceNotifications}>
|
notification={otherSpaceNotifications}>
|
||||||
<ImageIcon alt="All Spaces" src={Widget} size={8} />
|
<ImageIcon alt="All Spaces" src={Widget} size={8} />
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<h2 class="ellipsize whitespace-nowrap text-xl">
|
<h2 class="ellipsize whitespace-nowrap text-xl">
|
||||||
<RelayName {url} />
|
<RelayName {url} />
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {derived} from "svelte/store"
|
import {derived} from "svelte/store"
|
||||||
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
||||||
import {deriveRelay, createSearch, pubkey} from "@welshman/app"
|
import {deriveRelay, deriveRelayDisplay, createSearch, pubkey} from "@welshman/app"
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
const {url} = $props()
|
const {url} = $props()
|
||||||
|
|
||||||
const relay = deriveRelay(url)
|
const relay = deriveRelay(url)
|
||||||
|
const display = deriveRelayDisplay(url)
|
||||||
const chatPath = makeSpacePath(url, "chat")
|
const chatPath = makeSpacePath(url, "chat")
|
||||||
const goalsPath = makeSpacePath(url, "goals")
|
const goalsPath = makeSpacePath(url, "goals")
|
||||||
const threadsPath = makeSpacePath(url, "threads")
|
const threadsPath = makeSpacePath(url, "threads")
|
||||||
@@ -141,7 +142,9 @@
|
|||||||
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
|
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
|
||||||
onclick={openMenu}>
|
onclick={openMenu}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<strong class="flex items-center gap-1 relative">
|
<strong
|
||||||
|
class="flex items-center gap-1 relative tooltip tooltip-right"
|
||||||
|
data-tip={$display}>
|
||||||
<RelayName {url} class="ellipsize" />
|
<RelayName {url} class="ellipsize" />
|
||||||
<div
|
<div
|
||||||
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
|
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
|
||||||
|
|||||||
@@ -24,12 +24,13 @@
|
|||||||
const shouldNotifyForRoom = deriveShouldNotify(url, h)
|
const shouldNotifyForRoom = deriveShouldNotify(url, h)
|
||||||
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
|
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
|
||||||
const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false)
|
const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false)
|
||||||
|
const roomName = $derived($room?.name || h)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if roomType === RoomType.Voice}
|
{#if roomType === RoomType.Voice}
|
||||||
<VoiceRoomItem {url} {h} {replaceState} {notification} />
|
<VoiceRoomItem {url} {h} {replaceState} {notification} />
|
||||||
{:else}
|
{:else}
|
||||||
<SecondaryNavItem href={path} {replaceState} {notification}>
|
<SecondaryNavItem href={path} title={roomName} {replaceState} {notification}>
|
||||||
<RoomNameWithImage {url} {h} />
|
<RoomNameWithImage {url} {h} />
|
||||||
{#if showDifferenceIcon}
|
{#if showDifferenceIcon}
|
||||||
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
|
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {tick} from "svelte"
|
import {tick} from "svelte"
|
||||||
import {createSearch} from "@welshman/app"
|
import {debounce} from "throttle-debounce"
|
||||||
|
import {request} from "@welshman/net"
|
||||||
import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
|
import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent, Filter} from "@welshman/util"
|
||||||
import {MESSAGE} from "@welshman/util"
|
import {sortEventsDesc} from "@welshman/util"
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import {deriveEventsForUrl} from "@app/core/state"
|
import {CONTENT_KINDS} from "@app/core/state"
|
||||||
import {goToEvent} from "@app/util/routes"
|
import {goToEvent} from "@app/util/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -19,14 +20,16 @@
|
|||||||
|
|
||||||
const {url, h}: Props = $props()
|
const {url, h}: Props = $props()
|
||||||
|
|
||||||
const spaceMessages = deriveEventsForUrl(
|
|
||||||
url,
|
|
||||||
h ? [{kinds: [MESSAGE], "#h": [h]}] : [{kinds: [MESSAGE]}],
|
|
||||||
)
|
|
||||||
|
|
||||||
let term = $state("")
|
let term = $state("")
|
||||||
let show = $state(false)
|
let show = $state(false)
|
||||||
|
let results = $state<TrustedEvent[]>([])
|
||||||
|
let loading = $state(false)
|
||||||
let input: HTMLInputElement | undefined = $state()
|
let input: HTMLInputElement | undefined = $state()
|
||||||
|
let controller: AbortController | undefined
|
||||||
|
|
||||||
|
const relayStatus = $derived(
|
||||||
|
h ? `Searching this room on relay: ${url}.` : `Searching this space on relay: ${url}.`,
|
||||||
|
)
|
||||||
|
|
||||||
const open = () => {
|
const open = () => {
|
||||||
show = true
|
show = true
|
||||||
@@ -40,21 +43,53 @@
|
|||||||
const clear = () => {
|
const clear = () => {
|
||||||
term = ""
|
term = ""
|
||||||
show = false
|
show = false
|
||||||
|
loading = false
|
||||||
|
results = []
|
||||||
|
controller?.abort()
|
||||||
|
controller = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRelayUrls = () => [url]
|
||||||
|
|
||||||
|
const getFilter = (searchTerm: string): Filter =>
|
||||||
|
h
|
||||||
|
? {kinds: CONTENT_KINDS, "#h": [h], search: searchTerm}
|
||||||
|
: {kinds: CONTENT_KINDS, search: searchTerm}
|
||||||
|
|
||||||
|
const search = debounce(300, async (searchTerm: string) => {
|
||||||
|
controller?.abort()
|
||||||
|
|
||||||
|
if (!searchTerm.trim()) {
|
||||||
|
loading = false
|
||||||
|
results = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
controller = new AbortController()
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await request({
|
||||||
|
relays: getRelayUrls(),
|
||||||
|
autoClose: true,
|
||||||
|
signal: controller.signal,
|
||||||
|
filters: [getFilter(searchTerm.trim())],
|
||||||
|
})
|
||||||
|
|
||||||
|
results = sortEventsDesc(events)
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
||||||
|
results = []
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const onInput = () => {
|
const onInput = () => {
|
||||||
show = true
|
void search(term)
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchIndex = $derived.by(() =>
|
|
||||||
createSearch($spaceMessages, {
|
|
||||||
getValue: event => event.id,
|
|
||||||
fuseOptions: {keys: ["content"]},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const results = $derived(term ? searchIndex.searchOptions(term) : [])
|
|
||||||
|
|
||||||
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
|
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
|
||||||
|
|
||||||
const getAgeSection = (createdAt: number) => {
|
const getAgeSection = (createdAt: number) => {
|
||||||
@@ -122,10 +157,13 @@
|
|||||||
oninput={onInput} />
|
oninput={onInput} />
|
||||||
</label>
|
</label>
|
||||||
<div class="max-h-[65vh] overflow-y-auto">
|
<div class="max-h-[65vh] overflow-y-auto">
|
||||||
|
<p class="mb-2 text-xs opacity-70">{relayStatus}</p>
|
||||||
{#if !term}
|
{#if !term}
|
||||||
<p class="text-sm opacity-70">
|
<p class="text-sm opacity-70">
|
||||||
{h ? "Search for messages in this room." : "Search for messages across this space."}
|
{h ? "Search for content in this room." : "Search for content in this space."}
|
||||||
</p>
|
</p>
|
||||||
|
{:else if loading}
|
||||||
|
<p class="text-sm opacity-70">Searching...</p>
|
||||||
{:else if eventsByAge.size === 0}
|
{:else if eventsByAge.size === 0}
|
||||||
<p class="text-sm opacity-70">No results found.</p>
|
<p class="text-sm opacity-70">No results found.</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import {
|
||||||
|
currentVoiceSession,
|
||||||
|
DeviceKind,
|
||||||
|
supportsAudioOutputSelection,
|
||||||
|
switchVoiceActiveDevice,
|
||||||
|
type VoiceSession,
|
||||||
|
} from "@app/voice"
|
||||||
|
import {popModal} from "@app/util/modal"
|
||||||
|
|
||||||
|
const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => {
|
||||||
|
const livekitDeviceId = session.room.getActiveDevice(kind)
|
||||||
|
if (livekitDeviceId === undefined || livekitDeviceId === "" || livekitDeviceId === "default") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return livekitDeviceId
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioInputs = $state<MediaDeviceInfo[]>([])
|
||||||
|
let audioOutputs = $state<MediaDeviceInfo[]>([])
|
||||||
|
let selectedInput = $state("")
|
||||||
|
let selectedOutput = $state("")
|
||||||
|
|
||||||
|
const loadDevices = async () => {
|
||||||
|
if (!navigator.mediaDevices?.enumerateDevices) return
|
||||||
|
try {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
audioInputs = devices.filter(d => d.kind === "audioinput")
|
||||||
|
audioOutputs = devices.filter(d => d.kind === "audiooutput")
|
||||||
|
} catch {
|
||||||
|
audioInputs = []
|
||||||
|
audioOutputs = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadDevices()
|
||||||
|
navigator.mediaDevices?.addEventListener?.("devicechange", loadDevices)
|
||||||
|
return () => navigator.mediaDevices?.removeEventListener?.("devicechange", loadDevices)
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const session = $currentVoiceSession
|
||||||
|
if (!session) {
|
||||||
|
popModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
|
||||||
|
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
|
||||||
|
})
|
||||||
|
|
||||||
|
const onInputChange = () => {
|
||||||
|
void switchVoiceActiveDevice(DeviceKind.AudioInput, selectedInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onOutputChange = () => {
|
||||||
|
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDone = () => {
|
||||||
|
popModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output not support in Safari
|
||||||
|
const canPickOutput = supportsAudioOutputSelection()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Audio settings</ModalTitle>
|
||||||
|
<ModalSubtitle>Choose microphone and speaker for this call.</ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
<div class="flex flex-col gap-4 pt-2">
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Microphone</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<select
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
bind:value={selectedInput}
|
||||||
|
onchange={onInputChange}
|
||||||
|
aria-label="Microphone">
|
||||||
|
<option value="">Default microphone</option>
|
||||||
|
{#each audioInputs as d (d.deviceId)}
|
||||||
|
<option value={d.deviceId}>
|
||||||
|
{d.label || `Microphone ${d.deviceId.slice(0, 8)}…`}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
{#if canPickOutput}
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Speaker</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<select
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
bind:value={selectedOutput}
|
||||||
|
onchange={onOutputChange}
|
||||||
|
aria-label="Speaker">
|
||||||
|
<option value="">Default speaker</option>
|
||||||
|
{#each audioOutputs as d (d.deviceId)}
|
||||||
|
<option value={d.deviceId}>
|
||||||
|
{d.label || `Speaker ${d.deviceId.slice(0, 8)}…`}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-primary w-full" onclick={onDone}>Done</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
@@ -12,9 +12,11 @@
|
|||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import {AbortError, TimeoutError} from "$lib/util"
|
||||||
import {displayRoom} from "@app/core/state"
|
import {displayRoom} from "@app/core/state"
|
||||||
import {joinVoiceRoom} from "@app/voice"
|
import {joinVoiceRoom} from "@app/voice"
|
||||||
import {popModal} from "@app/util/modal"
|
import {popModal} from "@app/util/modal"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
@@ -45,6 +47,16 @@
|
|||||||
|
|
||||||
const goBack = () => history.back()
|
const goBack = () => history.back()
|
||||||
|
|
||||||
|
const handleJoinError = (e: unknown) => {
|
||||||
|
if (e instanceof AbortError) return
|
||||||
|
console.error("Failed to join voice room", e)
|
||||||
|
let message = "Failed to join voice room"
|
||||||
|
if (e instanceof TimeoutError)
|
||||||
|
message = "Connection timed out. Please check your network and try again."
|
||||||
|
else if (e instanceof Error) message = e.message
|
||||||
|
pushToast({theme: "error", message})
|
||||||
|
}
|
||||||
|
|
||||||
const joinVoice = async () => {
|
const joinVoice = async () => {
|
||||||
popModal()
|
popModal()
|
||||||
await joinVoiceRoom(
|
await joinVoiceRoom(
|
||||||
@@ -52,7 +64,7 @@
|
|||||||
h,
|
h,
|
||||||
startWithoutMic,
|
startWithoutMic,
|
||||||
startWithoutMic ? undefined : selectedDeviceId || undefined,
|
startWithoutMic ? undefined : selectedDeviceId || undefined,
|
||||||
)
|
).catch(handleJoinError)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
|
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
|
||||||
import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl"
|
import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl"
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
|
import Settings from "@assets/icons/settings.svg?dataurl"
|
||||||
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 VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
|
||||||
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
||||||
import {
|
import {
|
||||||
decodeRelay,
|
decodeRelay,
|
||||||
@@ -63,6 +65,10 @@
|
|||||||
await goto(makeRoomPath(targetRoom.url, targetRoom.h))
|
await goto(makeRoomPath(targetRoom.url, targetRoom.h))
|
||||||
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
|
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openAudioSettings = () => {
|
||||||
|
pushModal(VoiceCallAudioSettingsDialog)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if targetRoom}
|
{#if targetRoom}
|
||||||
@@ -100,6 +106,12 @@
|
|||||||
onclick={toggleMute}>
|
onclick={toggleMute}>
|
||||||
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
|
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-tip="Audio settings"
|
||||||
|
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
|
||||||
|
onclick={openAudioSettings}>
|
||||||
|
<Icon icon={Settings} size={4} />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
data-tip="Leave room"
|
data-tip="Leave room"
|
||||||
class="center tooltip tooltip-top btn btn-sm btn-square btn-error"
|
class="center tooltip tooltip-top btn btn-sm btn-square btn-error"
|
||||||
|
|||||||
@@ -191,7 +191,9 @@ export const PLATFORM_TERMS = import.meta.env.VITE_PLATFORM_TERMS
|
|||||||
|
|
||||||
export const PLATFORM_PRIVACY = import.meta.env.VITE_PLATFORM_PRIVACY
|
export const PLATFORM_PRIVACY = import.meta.env.VITE_PLATFORM_PRIVACY
|
||||||
|
|
||||||
export const PLATFORM_LOGO = PLATFORM_URL + "/logo.png"
|
export const PLATFORM_LOGO = import.meta.env.PROD
|
||||||
|
? PLATFORM_URL + "/logo.png"
|
||||||
|
: import.meta.env.VITE_PLATFORM_LOGO.replace(/^static/, "") || PLATFORM_URL + "/logo.png"
|
||||||
|
|
||||||
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
|
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
|
||||||
|
|
||||||
|
|||||||
@@ -298,10 +298,7 @@ const syncSpace = (url: string, rooms: string[]) => {
|
|||||||
url,
|
url,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
filters: [
|
filters: [
|
||||||
{kinds: relayKinds},
|
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...MESSAGE_KINDS]},
|
||||||
{kinds: roomMetaKinds},
|
|
||||||
{kinds: roomMemberKinds},
|
|
||||||
{kinds: MESSAGE_KINDS, since},
|
|
||||||
makeCommentFilter(CONTENT_KINDS, {since}),
|
makeCommentFilter(CONTENT_KINDS, {since}),
|
||||||
],
|
],
|
||||||
onEvent: event => {
|
onEvent: event => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Room as LiveKitRoom,
|
Room as LiveKitRoom,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
Track,
|
Track,
|
||||||
|
supportsAudioOutputSelection,
|
||||||
type AudioCaptureOptions,
|
type AudioCaptureOptions,
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
} from "livekit-client"
|
} from "livekit-client"
|
||||||
@@ -24,6 +25,8 @@ export const LIVEKIT_PARTICIPANTS = 39004
|
|||||||
|
|
||||||
export {checkRelayHasLivekit} from "$lib/livekit"
|
export {checkRelayHasLivekit} from "$lib/livekit"
|
||||||
|
|
||||||
|
export {supportsAudioOutputSelection}
|
||||||
|
|
||||||
export type VoiceSession = {
|
export type VoiceSession = {
|
||||||
url: string
|
url: string
|
||||||
h: string
|
h: string
|
||||||
@@ -43,6 +46,36 @@ export enum VoiceState {
|
|||||||
|
|
||||||
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
||||||
|
|
||||||
|
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
||||||
|
|
||||||
|
export enum DeviceKind {
|
||||||
|
AudioInput = "audioinput",
|
||||||
|
AudioOutput = "audiooutput",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const switchVoiceActiveDevice = async (
|
||||||
|
kind: DeviceKind,
|
||||||
|
targetDeviceId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
|
||||||
|
try {
|
||||||
|
await session.room.switchActiveDevice(kind, id)
|
||||||
|
} catch {
|
||||||
|
let label: string
|
||||||
|
switch (kind) {
|
||||||
|
case DeviceKind.AudioInput:
|
||||||
|
label = "microphone"
|
||||||
|
break
|
||||||
|
case DeviceKind.AudioOutput:
|
||||||
|
label = "speaker"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pushToast({theme: "error", message: `Error changing ${label}`})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
||||||
|
|
||||||
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
||||||
@@ -13,32 +14,35 @@
|
|||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
const active = $derived($page.url?.pathname?.startsWith(prefix || href || "bogus"))
|
const active = $derived($page.url?.pathname?.startsWith(prefix || href || "bogus"))
|
||||||
|
|
||||||
|
const wrapperClass = $derived(
|
||||||
|
cx("relative h-14 w-14 p-1", {
|
||||||
|
"tooltip tooltip-right": title,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const innerClass = $derived(
|
||||||
|
cx(
|
||||||
|
"flex h-full w-full cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-base-300",
|
||||||
|
restProps.class,
|
||||||
|
),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if onclick}
|
<div class={wrapperClass} data-tip={title}>
|
||||||
<Button {onclick} class="relative z-nav-item flex h-14 w-14 items-center justify-center p-1">
|
{#if onclick}
|
||||||
<div
|
<Button {onclick} class={innerClass}>
|
||||||
class="aspect-square flex-grow cursor-pointer rounded-full {restProps.class} flex items-center justify-center transition-colors hover:bg-base-300"
|
|
||||||
class:bg-base-300={active}
|
|
||||||
class:tooltip={title}
|
|
||||||
data-tip={title}>
|
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{#if !active && notification}
|
{#if !active && notification}
|
||||||
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div>
|
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</Button>
|
||||||
</Button>
|
{:else}
|
||||||
{:else}
|
<a {href} class={innerClass}>
|
||||||
<a {href} class="relative z-nav-item flex h-14 w-14 items-center justify-center p-1">
|
|
||||||
<div
|
|
||||||
class="aspect-square flex-grow cursor-pointer rounded-full {restProps.class} flex items-center justify-center transition-colors hover:bg-base-300"
|
|
||||||
class:bg-base-300={active}
|
|
||||||
class:tooltip={title}
|
|
||||||
data-tip={title}>
|
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{#if !active && notification}
|
{#if !active && notification}
|
||||||
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div>
|
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</a>
|
||||||
</a>
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
|
|||||||
@@ -21,37 +21,40 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
import {fade} from "@lib/transition"
|
import {fade} from "@lib/transition"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
|
|
||||||
const {children, href = "", notification = false, replaceState = false, ...restProps} = $props()
|
const {
|
||||||
|
children,
|
||||||
|
href = "",
|
||||||
|
title = "",
|
||||||
|
notification = false,
|
||||||
|
replaceState = false,
|
||||||
|
...restProps
|
||||||
|
} = $props()
|
||||||
|
|
||||||
const active = $derived($page.url.pathname === href)
|
const active = $derived($page.url.pathname === href)
|
||||||
|
const wrapperClass = $derived(
|
||||||
|
cx(restProps.class, "relative flex flex-shrink-0 items-center gap-3 text-left transition-all", {
|
||||||
|
"hover:bg-base-100 hover:text-base-content": true,
|
||||||
|
"text-base-content bg-base-100": active,
|
||||||
|
"tooltip tooltip-right": title,
|
||||||
|
}),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if href}
|
{#if href}
|
||||||
<a
|
<a {href} {...restProps} data-tip={title} data-sveltekit-replacestate={replaceState} class={wrapperClass}>
|
||||||
{href}
|
|
||||||
{...restProps}
|
|
||||||
data-sveltekit-replacestate={replaceState}
|
|
||||||
class="{restProps.class} relative flex flex-shrink-0 items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content"
|
|
||||||
class:text-base-content={active}
|
|
||||||
class:bg-base-100={active}>
|
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{#if notification}
|
{#if notification}
|
||||||
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade>
|
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade></div>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button {...restProps} data-tip={title} class={wrapperClass}>
|
||||||
{...restProps}
|
|
||||||
class="{restProps.class} relative flex flex-shrink-0 w-full items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content"
|
|
||||||
class:text-base-content={active}
|
|
||||||
class:bg-base-100={active}>
|
|
||||||
{#if notification}
|
{#if notification}
|
||||||
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade>
|
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade></div>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -31,7 +31,8 @@
|
|||||||
} from "@app/core/state"
|
} from "@app/core/state"
|
||||||
import {setSpaceMembershipOrder} from "@app/core/commands"
|
import {setSpaceMembershipOrder} from "@app/core/commands"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {goToSpace} from "@app/util/routes"
|
import {goToSpace, makeSpacePath} from "@app/util/routes"
|
||||||
|
import {notifications} from "@app/util/notifications"
|
||||||
|
|
||||||
const addSpace = () => pushModal(SpaceAdd)
|
const addSpace = () => pushModal(SpaceAdd)
|
||||||
|
|
||||||
@@ -254,9 +255,12 @@
|
|||||||
ondrop={e => onDrop(e, url)}
|
ondrop={e => onDrop(e, url)}
|
||||||
ondragend={onDragEnd}>
|
ondragend={onDragEnd}>
|
||||||
<Button
|
<Button
|
||||||
class="card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1] w-full"
|
class="card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1] w-full relative"
|
||||||
onclick={() => openSpace(url)}>
|
onclick={() => openSpace(url)}>
|
||||||
<RelaySummary hideFavorites {url} />
|
<RelaySummary hideFavorites {url} />
|
||||||
|
{#if $notifications.has(makeSpacePath(url))}
|
||||||
|
<div class="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary"></div>
|
||||||
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import daisyui from "daisyui"
|
|||||||
import themes from "daisyui/src/theming/themes"
|
import themes from "daisyui/src/theming/themes"
|
||||||
|
|
||||||
config({path: ".env.local"})
|
config({path: ".env.local"})
|
||||||
config({path: ".env.template"})
|
config({path: ".env"})
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import {sveltekit} from "@sveltejs/kit/vite"
|
|||||||
import svg from "@poppanator/sveltekit-svg"
|
import svg from "@poppanator/sveltekit-svg"
|
||||||
|
|
||||||
config({path: ".env.local"})
|
config({path: ".env.local"})
|
||||||
config({path: ".env.template"})
|
config({path: ".env"})
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Reference in New Issue
Block a user