Compare commits

..

28 Commits

Author SHA1 Message Date
Prat_09 613cad31c0 add start chat FAB (#152)
Co-authored-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
Co-committed-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
2026-04-07 17:02:40 +00:00
Jon Staab 3779a90f26 Tweak to chat item menu buttons 2026-04-07 09:51:25 -07:00
theAnuragMishra 7470f28f31 fix spacing around messages (#159)
Co-authored-by: theAnuragMishra <theanuragmishra@noreply.coracle.social>
Co-committed-by: theAnuragMishra <theanuragmishra@noreply.coracle.social>
2026-04-07 16:50:53 +00:00
hodlbod 17fb4e780b Clean up drafts implementation (#164) 2026-04-07 13:06:29 +00:00
userAdityaa 30c2a6ef79 persist drafts in memory (#155)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-07 12:06:29 +00:00
Jon Staab 0547e9513f Small css tweak 2026-04-06 09:21:59 -07:00
nayan9617 70e5172f1b fix/tooltip-clipping (#156)
Co-authored-by: nayan9617 <nayanp4925@gmail.com>
Co-committed-by: nayan9617 <nayanp4925@gmail.com>
2026-04-06 16:14:08 +00:00
Jon Staab 61c568a112 Formatting 2026-04-06 09:09:01 -07:00
Jon Staab ae2ba6f44d Tweak toast close button 2026-04-06 09:08:26 -07:00
Jon Staab f84006fbe4 Tweak button on profile page 2026-04-06 09:08:26 -07:00
priyanshu_bharti fed34a2747 show space name on hover in primary nav (#129) (#136)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-06 16:08:02 +00:00
userAdityaa 80df16f97b feat: redesign toast notifications for UX (#148)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-04 16:45:49 +00:00
junaiddshaukat 18cb245599 Remove room/space leave indications (#149)
Co-authored-by: junaiddshaukat <junaiddshaukat@noreply.coracle.social>
Co-committed-by: junaiddshaukat <junaiddshaukat@noreply.coracle.social>
2026-04-04 16:28:11 +00:00
Jon Staab fd6cc84be6 Simplify chat compose layout 2026-04-04 09:02:52 -07:00
Jon Staab 9311cab3b2 Move away from fixed positioned page elements because they act squirrely on android 2026-04-03 17:16:47 -07:00
userAdityaa fceccf47be fix(ui): hide report badge for non-admin users (#147)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-03 23:54:20 +00:00
Jon Staab fe20fbfd28 Add polls 2026-04-03 10:56:00 -07:00
junaiddshaukat 4f3a2a1660 Add space search to recent activity page (#59) (#119)
Co-authored-by: junaiddshaukat <junaiddshaukat@noreply.coracle.social>
Co-committed-by: junaiddshaukat <junaiddshaukat@noreply.coracle.social>
2026-04-03 16:58:35 +00:00
Jon Staab 1c8457a4bf Fix notification badge on mobile nav 2026-04-02 16:47:32 -07:00
Jon Staab 8710043a02 Fix env conventions again 2026-04-02 14:01:09 -07:00
Jon Staab dc46b42cb6 Fix platform logo 2026-04-02 13:49:01 -07:00
Jon Staab 2f1972e70a Add contributing file 2026-04-02 13:31:37 -07:00
mplorentz c5fcf12165 Fix error toast when failing to join room. (#113)
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-04-02 19:35:46 +00:00
mplorentz 61ed632579 Change audio devices in call (#112)
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-04-02 19:33:48 +00:00
Jon Staab 86f4b75c52 Merge subs to avoid hitting limits 2026-04-02 11:49:26 -07:00
Bhavishy b26ab916d5 feat: use NIP-50 relay-side search with scope selection (#114)
Co-authored-by: Bhavishy <bhavishyrocker2801@gmail.com>
Co-committed-by: Bhavishy <bhavishyrocker2801@gmail.com>
2026-04-02 18:49:18 +00:00
nayan9617 c882198206 fix: respect VITE_PLATFORM_LOGO with fallback (#116)
Co-authored-by: nayan9617 <nayanp4925@gmail.com>
Co-committed-by: nayan9617 <nayanp4925@gmail.com>
2026-04-02 17:52:44 +00:00
Jon Staab 4aef27ffd5 Fix xcode version
Docker / build-and-push-image (push) Successful in 15m54s
2026-04-02 07:08:16 -07:00
75 changed files with 2050 additions and 465 deletions
+1 -1
View File
@@ -9,4 +9,4 @@ build
# Env files (keep .env for build; exclude local overrides)
.env.local
.env.*.local
.env.*.local
View File
+2 -1
View File
@@ -1,6 +1,6 @@
# Env
.env
.env.local
.env.*.local
# Vite
vite.config.js.timestamp-*
@@ -73,3 +73,4 @@ GoogleService-Info.plist
# OS generated
.DS_Store
Thumbs.db
package-lock.json
+56
View File
@@ -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.
+3 -1
View File
@@ -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_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.
## Development
See [CONTRIBUTING.md](AGENTS.md).
See [CONTRIBUTING.md](CONTRIBUTING.md).
## Deployment
+2 -5
View File
@@ -2,11 +2,8 @@
temp_env=$(declare -p -x)
if [ -f .env.template ]; then
source .env.template
fi
if [ -f .env.local ]; then
source .env.local
if [ -f .env ]; then
source .env
fi
# Avoid overwriting env vars provided directly
+1 -1
View File
@@ -392,7 +392,7 @@
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.7.5;
MARKETING_VERSION = 1.7.2;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+1 -1
View File
@@ -2,7 +2,7 @@ import dotenv from "dotenv"
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
dotenv.config({path: ".env.local"})
dotenv.config({path: ".env.template"})
dotenv.config({path: ".env"})
export default defineConfig({
preset,
+12 -24
View File
@@ -364,6 +364,14 @@
/* tippy popover */
.tippy-target {
@apply pointer-events-none fixed inset-0 z-tooltip;
}
.tippy-target > * {
pointer-events: auto;
}
.tippy-box {
@apply rounded-box shadow-xl;
}
@@ -390,28 +398,12 @@ progress[value]::-webkit-progress-value {
/* content width for fixed elements */
.cw {
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
}
.cw-full {
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
}
.cb {
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
}
.ct {
@apply top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)];
.left-content {
@apply md:left-[calc(18.5rem+var(--sail))];
}
/* Keyboard open state adjustments */
body.keyboard-open .cb {
@apply bottom-sai;
}
body.keyboard-open .hide-on-keyboard {
display: none;
}
@@ -419,14 +411,10 @@ body.keyboard-open .hide-on-keyboard {
/* chat view */
.chat__compose {
@apply cb cw fixed z-compose;
@apply relative z-compose mb-14 flex-grow md:mb-0;
}
.chat__compose-zone {
@apply cb cw fixed z-compose;
}
.chat__compose-zone .chat__compose-inner {
.chat__compose .chat__compose-inner {
@apply min-w-0;
}
+36 -17
View File
@@ -20,24 +20,33 @@
import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {pushToast} from "@app/util/toast"
import {canEnforceNip70} from "@app/core/commands"
type Values = {
d: string
title: string
content: string | object
location: string
start?: number
end?: number
}
type Props = {
url: string
h?: string
header: Snippet
initialValues?: {
d: string
title: string
content: string
location: string
start: number
end: number
}
initialValues?: Values
}
const {url, h, header, initialValues}: Props = $props()
let {url, h, header, initialValues}: Props = $props()
const draftKey = new DraftKey<Values>(`calendar:${url}:${h ?? ""}`)
if (!initialValues) {
initialValues = draftKey.get()
}
const shouldProtect = canEnforceNip70(url)
@@ -74,9 +83,9 @@
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = [
["d", initialValues?.d || randomId()],
["d", d],
["title", title],
["location", location || ""],
["location", location],
["start", start.toString()],
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
@@ -95,17 +104,27 @@
pushToast({message: "Your event has been saved!"})
publishThunk({event, relays: [url]})
draftKey.clear()
history.back()
}
const content = initialValues?.content || ""
const editor = makeEditor({url, submit, uploading, content})
let title = $state(initialValues?.title || "")
let location = $state(initialValues?.location || "")
const d = $state(initialValues?.d ?? randomId())
let title = $state(initialValues?.title ?? "")
let location = $state(initialValues?.location ?? "")
let start: number | undefined = $state(initialValues?.start)
let end: number | undefined = $state(initialValues?.end)
let endDirty = Boolean(initialValues?.end)
let endDirty = $state(Boolean(initialValues?.end))
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({url, submit, uploading, onChange, content})
$effect(() => {
draftKey.set({d, title, location, start, end, content})
})
$effect(() => {
if (!endDirty && start) {
+6 -19
View File
@@ -55,6 +55,7 @@
import ThunkToast from "@app/components/ThunkToast.svelte"
import {userSettingsValues, deriveChat} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {DraftKey} from "@app/util/drafts"
import {makeDelete, prependParent} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
@@ -66,6 +67,7 @@
const {pubkeys, info}: Props = $props()
const chat = deriveChat(pubkeys)
const draftKey = new DraftKey<{content?: string | object}>(`dm:${$chat?.id}`)
const others = remove($pubkey!, pubkeys)
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
@@ -196,8 +198,6 @@
let compose: ChatCompose | undefined = $state()
let parent: TrustedEvent | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
let chatCompose: HTMLElement | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state()
const elements = $derived.by(() => {
const elements = []
@@ -233,20 +233,6 @@
for (const pubkey of others) {
loadMessagingRelayList(pubkey)
}
const observer = new ResizeObserver(() => {
if (dynamicPadding && chatCompose) {
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
}
})
observer.observe(chatCompose!)
observer.observe(dynamicPadding!)
return () => {
observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
}
})
setTimeout(() => {
@@ -294,7 +280,6 @@
</PageBar>
<PageContent class="flex flex-col-reverse gap-2 pt-4">
<div bind:this={dynamicPadding}></div>
{#if missingRelayLists.length > 0}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
@@ -335,9 +320,10 @@
</Spinner>
{@render info?.()}
</p>
<div class="h-screen"></div>
</PageContent>
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
<div class="chat__compose bg-base-200">
<div>
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
@@ -352,7 +338,8 @@
{onSubmit}
{onEscape}
{onEditPrevious}
content={eventToEdit?.content}
initialValues={eventToEdit}
draftKey={eventToEdit ? undefined : draftKey}
disabled={Boolean(missingRelayLists.length)} />
{/key}
</div>
+32 -4
View File
@@ -10,16 +10,33 @@
import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {type DraftKey} from "@app/util/drafts"
type Values = {
content?: string | object
}
type Props = {
content?: string
disabled?: boolean
draftKey?: DraftKey<Values>
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
initialValues?: Values
}
const {content, disabled = false, onEscape, onEditPrevious, onSubmit}: Props = $props()
let {
initialValues,
disabled = false,
draftKey,
onEscape,
onEditPrevious,
onSubmit,
}: Props = $props()
if (!initialValues) {
initialValues = draftKey?.get()
}
const autofocus = !isMobile && !disabled
@@ -59,18 +76,29 @@
onSubmit({content, tags})
draftKey?.clear()
ed.chain().clearContent().run()
}
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
content,
autofocus,
submit,
uploading,
onChange,
aggressive: true,
encryptFiles: true,
})
$effect(() => {
draftKey?.set({content})
})
onMount(async () => {
const ed = await editor
ed.view.dom.addEventListener("keydown", handleKeyDown)
@@ -95,7 +123,7 @@
{/if}
</Button>
<div class={editorClass} aria-disabled={disabled}>
<EditorContent {editor} />
<EditorContent {autofocus} {editor} />
</div>
<Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
-9
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import {assoc} from "@welshman/lib"
import ChatSquare from "@assets/icons/chat-square.svg?dataurl"
import Check from "@assets/icons/check.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import BellOff from "@assets/icons/bell-off.svg?dataurl"
@@ -8,13 +7,9 @@
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ChatStart from "@app/components/ChatStart.svelte"
import {setChecked} from "@app/util/notifications"
import {pushModal} from "@app/util/modal"
import {notificationSettings} from "@app/core/state"
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
const markAsRead = () => {
setChecked("/chat/*")
history.back()
@@ -28,10 +23,6 @@
<Modal>
<ModalBody>
<div class="flex flex-col gap-2">
<Button class="btn btn-primary" onclick={startChat}>
<Icon size={5} icon={ChatSquare} />
Start chat
</Button>
<Button class="btn btn-neutral" onclick={markAsRead}>
<Icon size={5} icon={Check} />
Mark all read
+40 -21
View File
@@ -20,25 +20,34 @@
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70, uploadFile} from "@app/core/commands"
type Values = {
d: string
title: string
content: string | object
price: number
currency: string
images: (string | File)[]
status: string
topics: string[]
}
type Props = {
url: string
h?: string
header: Snippet
initialValues?: {
d?: string
title?: string
content?: string
price?: number
currency?: string
images?: string[]
status?: string
topics?: string[]
}
initialValues?: Values
}
const {url, h, header, initialValues}: Props = $props()
let {url, h, header, initialValues}: Props = $props()
const draftKey = new DraftKey<Values>(`classified:${url}:${h ?? ""}`)
if (!initialValues) {
initialValues = draftKey.get()
}
const shouldProtect = canEnforceNip70(url)
@@ -66,7 +75,7 @@
}
const tags = [
["d", initialValues?.d || randomId()],
["d", d],
["title", title],
["summary", content],
["price", String(price), currency],
@@ -110,22 +119,32 @@
event: makeEvent(CLASSIFIED, {content, tags}),
})
draftKey.clear()
history.back()
} finally {
loading = false
}
}
const content = initialValues?.content || ""
const editor = makeEditor({url, submit, content})
let loading = $state(false)
let title = $state(initialValues?.title || "")
let status = $state(initialValues?.status || "active")
let price = $state(Number(initialValues?.price || 0))
let currency = $state(initialValues?.currency || "SAT")
let images = $state<(string | File)[]>(initialValues?.images || [])
let topics = $state(uniq(removeUndefined((initialValues?.topics || []).map(normalizeTopic))))
const d = $state(initialValues?.d ?? randomId())
let title = $state(initialValues?.title ?? "")
let status = $state(initialValues?.status ?? "active")
let price = $state(initialValues?.price ?? 0)
let currency = $state(initialValues?.currency ?? "SAT")
let images = $state(initialValues?.images ?? [])
let topics = $state(uniq(removeUndefined(initialValues?.topics?.map(normalizeTopic) ?? [])))
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({url, submit, onChange, content})
$effect(() => {
draftKey.set({d, title, status, price, currency, images, topics, content})
})
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
+10
View File
@@ -4,6 +4,7 @@
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import Revote from "@assets/icons/revote.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import {pushModal} from "@app/util/modal"
@@ -11,6 +12,7 @@
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
import GoalCreate from "@app/components/GoalCreate.svelte"
import PollCreate from "@app/components/PollCreate.svelte"
type Props = {
url: string
@@ -28,6 +30,8 @@
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
const createPoll = () => pushModal(PollCreate, {url, h})
let ul: Element
onMount(() => {
@@ -60,4 +64,10 @@
Create Thread
</Button>
</li>
<li>
<Button onclick={createPoll}>
<Icon size={4} icon={Revote} />
Ask a Question
</Button>
</li>
</ul>
+26 -6
View File
@@ -10,13 +10,19 @@
import {publishComment, canEnforceNip70} from "@app/core/commands"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {pushToast} from "@app/util/toast"
type Values = {
content?: string | object
}
const {url, event, onClose, onSubmit} = $props()
const draftKey = new DraftKey<Values>(`reply:${event.id}`)
const initialValues = draftKey.get()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
const autofocus = !isMobile
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
@@ -38,13 +44,23 @@
})
}
draftKey.clear()
onSubmit(publishComment({event, content, tags, relays: [url]}))
}
const editor = makeEditor({url, submit, uploading, autofocus: !isMobile})
let form: HTMLElement
let spacer: HTMLElement
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({url, submit, uploading, content, onChange})
$effect(() => {
draftKey.set({content})
})
onMount(() => {
setTimeout(() => {
@@ -64,11 +80,15 @@
</script>
<div bind:this={spacer}></div>
<form in:fly bind:this={form} onsubmit={preventDefault(submit)} class="cb cw fixed z-feature pt-3">
<form
in:fly
bind:this={form}
onsubmit={preventDefault(submit)}
class="left-content bottom-sai right-sai ml-2 pl-2 fixed z-feature">
<div class="card2 mx-2 my-2 bg-alt shadow-md">
<div class="relative">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
<EditorContent {autofocus} {editor} />
</div>
<Button
data-tip="Add an image"
+41 -10
View File
@@ -20,14 +20,28 @@
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70} from "@app/core/commands"
type Values = {
title: string
content: string | object
amount: number
}
type Props = {
url: string
h?: string
initialValues?: Values
}
const {url, h}: Props = $props()
let {url, h, initialValues}: Props = $props()
const draftKey = new DraftKey<Values>(`goal:${url}:${h ?? ""}`)
if (!initialValues) {
initialValues = draftKey.get()
}
const shouldProtect = canEnforceNip70(url)
@@ -40,7 +54,7 @@
const submit = async () => {
if ($uploading) return
if (!content) {
if (!title) {
return pushToast({
theme: "error",
message: "Please provide a title for your funding goal.",
@@ -48,9 +62,9 @@
}
const ed = await editor
const summary = ed.getText({blockSeparator: "\n"}).trim()
const content = ed.getText({blockSeparator: "\n"}).trim()
if (!summary.trim()) {
if (!content.trim()) {
return pushToast({
theme: "error",
message: "Please provide details about your funding goal.",
@@ -59,7 +73,7 @@
const tags = [
...ed.storage.nostr.getEditorTags(),
["summary", summary],
["summary", content],
["amount", String(amount)],
["relays", url],
]
@@ -74,16 +88,33 @@
publishThunk({
relays: [url],
event: makeEvent(ZAP_GOAL, {content, tags}),
event: makeEvent(ZAP_GOAL, {content: title, tags}),
})
draftKey.clear()
history.back()
}
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
let title = $state(initialValues?.title ?? "")
let amount = $state(initialValues?.amount ?? 1000)
let content = $state(initialValues?.content ?? "")
let content = $state("")
let amount = $state(1000)
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
submit,
uploading,
onChange,
placeholder: "What's on your mind?",
content,
})
$effect(() => {
draftKey.update({title, content, amount})
})
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
@@ -102,7 +133,7 @@
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={content}
bind:value={title}
class="grow"
type="text"
placeholder="What do funds go towards?" />
+4
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
import {Poll} from "nostr-tools/kinds"
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
import NoteContentThread from "@app/components/NoteContentThread.svelte"
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
import NoteContentPoll from "@app/components/NoteContentPoll.svelte"
import Content from "@app/components/Content.svelte"
const props: ComponentProps<typeof Content> = $props()
@@ -19,6 +21,8 @@
<NoteContentClassified {...props} />
{:else if props.event.kind === ZAP_GOAL}
<NoteContentGoal {...props} />
{:else if props.event.kind === Poll}
<NoteContentPoll {...props} />
{:else}
<Content {...props} />
{/if}
@@ -1,10 +1,12 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
import {Poll} from "nostr-tools/kinds"
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
import NoteContentMinimalPoll from "@app/components/NoteContentMinimalPoll.svelte"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
const props: ComponentProps<typeof ContentMinimal> = $props()
@@ -19,6 +21,8 @@
<NoteContentMinimalClassified {...props} />
{:else if props.event.kind === ZAP_GOAL}
<NoteContentMinimalGoal {...props} />
{:else if props.event.kind === Poll}
<NoteContentMinimalPoll {...props} />
{:else}
<ContentMinimal {...props} />
{/if}
@@ -0,0 +1,19 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {derived} from "svelte/store"
import {PollResponse} from "nostr-tools/kinds"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
import {deriveEvents} from "@app/core/state"
import {getPollResults} from "@app/util/polls"
const props: ComponentProps<typeof ContentMinimal> = $props()
const responses = deriveEvents([{kinds: [PollResponse], "#e": [props.event.id]}])
const results = derived(responses, $responses => getPollResults(props.event, $responses))
</script>
<div class="flex flex-col gap-0">
<ContentMinimal {...props} />
<span class="text-xs opacity-50">{$results.voters} voter{$results.voters === 1 ? "" : "s"}</span>
</div>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {onMount} from "svelte"
import {request} from "@welshman/net"
import {PollResponse} from "nostr-tools/kinds"
import PollVotes from "@app/components/PollVotes.svelte"
import Content from "@app/components/Content.svelte"
const props: ComponentProps<typeof Content> = $props()
onMount(() => {
if (!props.url) {
return
}
request({
relays: [props.url],
filters: [{kinds: [PollResponse], "#e": [props.event.id]}],
})
})
</script>
<div class="flex flex-col gap-3">
<Content event={props.event} showEntire url={props.url} />
{#if props.url}
<PollVotes url={props.url} event={props.event} />
{/if}
</div>
+255
View File
@@ -0,0 +1,255 @@
<script lang="ts">
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
import {makeEvent} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {Poll} from "nostr-tools/kinds"
import {isMobile, preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl"
import PlusCircle from "@assets/icons/add-circle.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {canEnforceNip70} from "@app/core/commands"
import {DraftKey} from "@app/util/drafts"
import type {PollType} from "@app/util/polls"
type Option = {
id: string
value: string
}
type Values = {
title: string
pollType: PollType
endsAt?: number
options: Option[]
}
type Props = {
url: string
h?: string
}
const {url, h}: Props = $props()
const draftKey = new DraftKey<Values>(`poll:${url}:${h ?? ""}`)
const initialValues = draftKey.get()
const shouldProtect = canEnforceNip70(url)
const back = () => history.back()
const addOption = () => {
options = [...options, {id: randomId(), value: ""}]
}
const removeOption = (id: string) => {
options = options.filter(option => option.id !== id)
}
const updateOption = (id: string, value: string) => {
options = options.map(option => (option.id === id ? {...option, value} : option))
}
const reorderOptions = (targetId: string) => {
if (!draggedOptionId) {
return
}
const sourceIndex = options.findIndex(option => option.id === draggedOptionId)
const targetIndex = options.findIndex(option => option.id === targetId)
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
return
}
options = insertAt(targetIndex, options[sourceIndex], removeAt(sourceIndex, options))
}
const onDragStart = (e: DragEvent, id: string) => {
draggedOptionId = id
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move"
e.dataTransfer.setData("text/plain", id)
}
}
const onDragOver = (e: DragEvent, targetId: string) => {
e.preventDefault()
reorderOptions(targetId)
}
const onDrop = (e: DragEvent, targetId: string) => {
e.preventDefault()
reorderOptions(targetId)
draggedOptionId = undefined
}
const onDragEnd = () => {
draggedOptionId = undefined
}
const submit = async () => {
if (!title.trim()) {
return pushToast({theme: "error", message: "Please provide a title for your poll."})
}
const nonEmptyOptions = removeUndefined(options.map(option => option.value.trim() || undefined))
if (nonEmptyOptions.length < 2) {
return pushToast({theme: "error", message: "Please provide at least two options."})
}
if (endsAt && endsAt <= now()) {
return pushToast({theme: "error", message: "End time must be in the future."})
}
const tags: string[][] = [
...nonEmptyOptions.map(option => ["option", randomId(), option]),
["polltype", pollType],
["relay", url],
]
if (endsAt) {
tags.push(["endsAt", String(endsAt)])
}
if (h) {
tags.push(["h", h])
}
if (await shouldProtect) {
tags.push(PROTECTED)
}
publishThunk({
relays: [url],
event: makeEvent(Poll, {content: title.trim(), tags}),
})
draftKey.clear()
history.back()
}
let draggedOptionId = $state<string | undefined>()
let title = $state(initialValues?.title ?? "")
let pollType = $state<PollType>(initialValues?.pollType ?? "singlechoice")
let endsAt = $state<number | undefined>(initialValues?.endsAt)
let options = $state<Option[]>(
initialValues?.options ?? [
{id: randomId(), value: "Yes"},
{id: randomId(), value: "No"},
],
)
$effect(() => {
draftKey.set({title, pollType, endsAt, options})
})
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Create a Poll</ModalTitle>
<ModalSubtitle>Ask a question and collect votes right in the feed.</ModalSubtitle>
</ModalHeader>
<div class="col-8 relative">
<Field>
{#snippet label()}
<p>Question*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={title}
class="grow"
type="text"
placeholder="What would you like to ask?" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Options*</p>
{/snippet}
{#snippet input()}
<div class="flex flex-col gap-2" role="list">
{#each options as option, index (option.id)}
<div
class="flex items-center gap-2"
draggable="true"
role="listitem"
ondragstart={e => onDragStart(e, option.id)}
ondragover={e => onDragOver(e, option.id)}
ondrop={e => onDrop(e, option.id)}
ondragend={onDragEnd}>
<div class="cursor-move opacity-70" aria-label="Drag handle">
<Icon icon={HamburgerMenu} size={4} />
</div>
<label class="input input-bordered flex w-full items-center gap-2">
<input
value={option.value}
class="grow"
type="text"
placeholder={`Option ${index + 1}`}
oninput={e => updateOption(option.id, e.currentTarget.value)} />
</label>
<Button class="btn btn-ghost btn-sm" onclick={() => removeOption(option.id)}>
<Icon icon={MinusCircle} size={4} />
</Button>
</div>
{/each}
<Button class="btn btn-outline btn-sm self-end" onclick={addOption}>
<Icon icon={PlusCircle} size={4} />
Add option
</Button>
</div>
{/snippet}
</Field>
<div class="flex flex-col gap-2">
<FieldInline>
{#snippet label()}
Poll type
{/snippet}
{#snippet input()}
<select class="select select-bordered w-full max-w-xs" bind:value={pollType}>
<option value="singlechoice">Single choice</option>
<option value="multiplechoice">Multiple choice</option>
</select>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
Ends at
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={endsAt} />
{/snippet}
</FieldInline>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary">Create Poll</Button>
</ModalFooter>
</Modal>
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import RoomLink from "@app/components/RoomLink.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import {makePollPath} from "@app/util/routes"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const h = getTagValue("h", event.tags)
</script>
<Link
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-md"
href={makePollPath(url, event.id)}>
<NoteContent {event} {url} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} {url} />
{#if h}
in <RoomLink {url} {h} />
{/if}
</span>
<CommentActions segment="polls" showActivity {url} {event} />
</div>
</Link>
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import {tweened} from "svelte/motion"
import type {TrustedEvent} from "@welshman/util"
import {noop} from "@welshman/lib"
import {stopPropagation} from "@lib/html"
import {getPollType, isPollClosed} from "@app/util/polls"
type Props = {
event: TrustedEvent
option: {id: string; label: string}
results: {voters: number; options: {id: string; votes: number}[]}
selectedIds: string[]
setSingleChoice: (id: string) => void
toggleMultipleChoice: (id: string) => void
}
const {event, option, results, selectedIds, setSingleChoice, toggleMultipleChoice}: Props =
$props()
const pollType = getPollType(event)
const closed = isPollClosed(event)
const selected = $derived(
pollType === "singlechoice" ? selectedIds[0] === option.id : selectedIds.includes(option.id),
)
const onselect = () =>
pollType === "singlechoice" ? setSingleChoice(option.id) : toggleMultipleChoice(option.id)
const votes = $derived(results.options.find(r => r.id === option.id)?.votes || 0)
const maxVotes = $derived(Math.max(...results.options.map(r => r.votes), 1))
const tweenedVotes = tweened(votes, {duration: 300})
const tweenedMax = tweened(maxVotes, {duration: 300})
$effect(() => {
tweenedVotes.set(votes)
})
$effect(() => {
tweenedMax.set(maxVotes)
})
</script>
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
<div class="flex items-center justify-between gap-2">
<label class="flex min-w-0 flex-grow items-center gap-2">
{#if !closed}
{#if pollType === "singlechoice"}
<input
name={event.id}
type="radio"
class="radio radio-primary radio-sm"
checked={selected}
onclick={stopPropagation(noop)}
onchange={onselect} />
{:else}
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
checked={selected}
onclick={stopPropagation(noop)}
onchange={onselect} />
{/if}
{/if}
<span class="truncate">{option.label}</span>
</label>
<span class="whitespace-nowrap text-xs opacity-75">{votes} vote{votes === 1 ? "" : "s"}</span>
</div>
<progress class="progress progress-primary" value={$tweenedVotes} max={$tweenedMax}></progress>
</div>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import {onDestroy} from "svelte"
import type {TrustedEvent} from "@welshman/util"
import {pubkey, publishThunk, abortThunk} from "@welshman/app"
import {PollResponse} from "nostr-tools/kinds"
import {formatTimestampRelative} from "@welshman/lib"
import {deriveEvents} from "@app/core/state"
import {pushToast} from "@app/util/toast"
import {makePollResponse} from "@app/core/commands"
import PollOption from "@app/components/PollOption.svelte"
import {
getPollEndsAt,
getPollOptions,
getPollResponseSelections,
getPollResults,
getPollType,
isPollClosed,
} from "@app/util/polls"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const responses = deriveEvents([{kinds: [PollResponse], "#e": [event.id]}])
const pollType = getPollType(event)
const options = getPollOptions(event)
const closed = isPollClosed(event)
const endsAt = getPollEndsAt(event)
const publishDelay = pollType === "multiplechoice" ? 10_000 : undefined
const getOwnResponse = (responses: TrustedEvent[]) => {
let latest: TrustedEvent | undefined
for (const response of responses) {
if (response.pubkey !== $pubkey) {
continue
}
if (!latest || response.created_at > latest.created_at) {
latest = response
}
}
return latest
}
const publishSelection = (selection: string[]) => {
if (activeThunk) {
abortThunk(activeThunk)
}
if (selection.length === 0) {
activeThunk = undefined
return
}
activeThunk = publishThunk({
relays: [url],
event: makePollResponse({event, selectedIds: selection}),
delay: publishDelay,
})
}
const publishCurrentSelection = () => {
const selection = pollType === "singlechoice" ? selectedIds.slice(0, 1) : selectedIds
if (selection.length === 0) {
return pushToast({theme: "error", message: "Please select at least one option."})
}
publishSelection(selection)
}
const results = $derived(getPollResults(event, $responses))
const ownResponse = $derived(getOwnResponse($responses))
const setSingleChoice = (id: string) => {
selectedIds = [id]
publishCurrentSelection()
}
const toggleMultipleChoice = (id: string) => {
selectedIds = selectedIds.includes(id)
? selectedIds.filter(selectedId => selectedId !== id)
: [...selectedIds, id]
publishCurrentSelection()
}
let selectedIds = $state<string[]>([])
let activeThunk: ReturnType<typeof publishThunk> | undefined
$effect(() => {
if (ownResponse) {
selectedIds = getPollResponseSelections(ownResponse, pollType)
}
})
onDestroy(() => {
if (activeThunk) {
abortThunk(activeThunk)
}
})
</script>
<div class="flex flex-col gap-2">
{#each options as option (option.id)}
<PollOption {event} {option} {results} {selectedIds} {setSingleChoice} {toggleMultipleChoice} />
{/each}
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm opacity-75">
{pollType === "multiplechoice" ? "Multiple choice" : "Single choice"}
{#if endsAt}
{#if closed}
• Ended {formatTimestampRelative(endsAt)}
{:else}
• Ends {formatTimestampRelative(endsAt)}
{/if}
{/if}
</div>
<div class="text-sm opacity-75">{results.voters} vote{results.voters === 1 ? "" : "s"}</div>
</div>
</div>
+10 -13
View File
@@ -14,7 +14,7 @@
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {notifications} from "@app/util/notifications"
import {goToChat} from "@app/util/routes"
import {goToChat, makeSpacePath} from "@app/util/routes"
type Props = {
children?: Snippet
@@ -26,22 +26,20 @@
const showSettingsMenu = () => pushModal(MenuSettings)
const anySpaceNotifications = $derived($userSpaceUrls.some(p => $notifications.has(p)))
const anySpaceNotifications = $derived(
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
)
</script>
<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-2 md:block">
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
<PrimaryNavSpaces />
{#if PLATFORM_RELAYS.length > 0}
<Divider />
{/if}
<div>
<PrimaryNavItem
title="Settings"
href="/settings/profile"
prefix="/settings"
class="tooltip-right">
<div class="flex flex-col">
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings">
{#if $userProfile?.picture}
<ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} />
{:else}
@@ -51,11 +49,10 @@
<PrimaryNavItem
title="Messages"
onclick={chatHandler}
class="tooltip-right"
notification={$notifications.has("/chat")}>
<ImageIcon alt="Messages" src={Letter} size={8} />
</PrimaryNavItem>
<PrimaryNavItem title="Search" href="/people" class="tooltip-right">
<PrimaryNavItem title="Search" href="/people">
<ImageIcon alt="Search" src={Magnifier} size={8} />
</PrimaryNavItem>
</div>
@@ -66,10 +63,10 @@
<!-- a little extra something for ios -->
<div
class="bottom-nav hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden">
class="hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden">
</div>
<div
class="bottom-nav hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
class="hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
<div class="content-padding-x content-sizing flex justify-between px-2">
<div class="flex gap-2 sm:gap-6">
<PrimaryNavItem title="Search" href="/people">
@@ -1,5 +1,5 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import {deriveRelayDisplay} from "@welshman/app"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import {makeSpacePath, goToSpace} from "@app/util/routes"
@@ -12,11 +12,13 @@
const {url}: Props = $props()
const onClick = () => goToSpace(url)
const display = $derived(deriveRelayDisplay(url))
</script>
<PrimaryNavItem
onclick={onClick}
title={displayRelayUrl(url)}
title={$display}
class="tooltip-right"
notification={$notifications.has(makeSpacePath(url))}>
<RelayIcon {url} size={10} class="rounded-full" />
+2 -3
View File
@@ -12,7 +12,7 @@
const itemHeight = 56
const navPadding = 8 * itemHeight
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight))
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
</script>
@@ -23,7 +23,7 @@
{#each PLATFORM_RELAYS as url (url)}
<PrimaryNavItemSpace {url} />
{: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} />
</PrimaryNavItem>
<Divider />
@@ -33,7 +33,6 @@
<PrimaryNavItem
href="/spaces"
title="All Spaces"
class="tooltip-right"
prefix="no-highlight"
notification={otherSpaceNotifications}>
<ImageIcon alt="All Spaces" src={Widget} size={8} />
+4 -2
View File
@@ -23,7 +23,7 @@
import Icon from "@lib/components/Icon.svelte"
import Reaction from "@app/components/Reaction.svelte"
import ReportDetails from "@app/components/ReportDetails.svelte"
import {REACTION_KINDS} from "@app/core/state"
import {REACTION_KINDS, deriveUserIsSpaceAdmin} from "@app/core/state"
import {pushModal} from "@app/util/modal"
interface Props {
@@ -78,6 +78,8 @@
}
}
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const onReportClick = () => pushModal(ReportDetails, {url, event})
const reportReasons = $derived(uniq(map(e => getTag("e", e.tags)?.[2], $reports.values())))
@@ -118,7 +120,7 @@
{#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children}
<div class="flex min-w-0 flex-wrap gap-2">
{#if url && $reports.length > 0}
{#if url && $reports.length > 0 && $userIsAdmin}
<button
type="button"
data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
+1 -1
View File
@@ -35,7 +35,7 @@
</div>
{/if}
</div>
<div>
<div class="min-w-0">
<h2 class="ellipsize whitespace-nowrap text-xl">
<RelayName {url} />
</h2>
+33 -5
View File
@@ -12,18 +12,29 @@
import ComposeMenu from "@app/components/ComposeMenu.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {onDestroy, onMount} from "svelte"
type Values = {
content?: string | object
}
type Props = {
url?: string
h?: string
content?: string
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
initialValues?: Values
}
const {url, h, content, onEscape, onEditPrevious, onSubmit}: Props = $props()
let {url, h, initialValues, onEscape, onEditPrevious, onSubmit}: Props = $props()
const draftKey = url || h ? new DraftKey<Values>(`room:${url ?? ""}:${h ?? ""}`) : undefined
if (!initialValues) {
initialValues = draftKey?.get()
}
const autofocus = !isMobile
@@ -61,12 +72,29 @@
onSubmit({content, tags})
draftKey?.clear()
ed.chain().clearContent().run()
}
const editor = makeEditor({url, content, autofocus, submit, uploading, aggressive: true})
let popover: Instance | undefined = $state()
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
content,
submit,
uploading,
onChange,
aggressive: true,
})
$effect(() => {
draftKey?.set({content})
})
onMount(async () => {
const ed = await editor
@@ -105,7 +133,7 @@
</Tippy>
</div>
<div class="chat-editor flex-grow overflow-hidden">
<EditorContent {editor} />
<EditorContent {autofocus} {editor} />
</div>
<Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
+26 -25
View File
@@ -22,6 +22,7 @@
import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import Tooltip from "@lib/components/Tooltip.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
@@ -206,39 +207,39 @@
<strong class="text-lg">Room Permissions</strong>
<div class="flex gap-2 flex-wrap">
{#if $room?.isRestricted}
<Button
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
data-tip="Only members can send messages.">
<Icon size={4} icon={Microphone} /> Restricted
</Button>
<Tooltip content="Only members can send messages.">
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
<Icon size={4} icon={Microphone} /> Restricted
</Button>
</Tooltip>
{/if}
{#if $room?.isPrivate}
<Button
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
data-tip="Only members can view messages.">
<Icon size={4} icon={Lock} /> Private
</Button>
<Tooltip content="Only members can view messages.">
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
<Icon size={4} icon={Lock} /> Private
</Button>
</Tooltip>
{/if}
{#if $room?.isHidden}
<Button
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
data-tip="This room is not visible to non-members.">
<Icon size={4} icon={EyeClosed} /> Hidden
</Button>
<Tooltip content="This room is not visible to non-members.">
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
<Icon size={4} icon={EyeClosed} /> Hidden
</Button>
</Tooltip>
{/if}
{#if $room?.isClosed}
<Button
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
data-tip="Requests to join this room will be ignored.">
<Icon size={4} icon={MinusCircle} /> Closed
</Button>
<Tooltip content="Requests to join this room will be ignored.">
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
<Icon size={4} icon={MinusCircle} /> Closed
</Button>
</Tooltip>
{/if}
{#if !$room?.isRestricted && !$room?.isPrivate && !$room?.isHidden && !$room?.isClosed}
<Button
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
data-tip="This room has no additional access controls.">
<Icon size={4} icon={Eye} /> Public
</Button>
<Tooltip content="This room has no additional access controls.">
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
<Icon size={4} icon={Eye} /> Public
</Button>
</Tooltip>
{/if}
</div>
</div>
+9 -4
View File
@@ -37,6 +37,7 @@
event: TrustedEvent
replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean
addSpaceBelow?: boolean
inert?: boolean
canEdit: (event: TrustedEvent) => boolean
onEdit: (event: TrustedEvent) => void
@@ -47,6 +48,7 @@
event,
replyTo = undefined,
showPubkey = false,
addSpaceBelow = false,
inert = false,
canEdit,
onEdit,
@@ -77,17 +79,20 @@
<TapTarget
data-event={event.id}
onTap={inert ? null : onTap}
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left hover:bg-base-100/50">
class={cx(
"group relative flex w-full cursor-default flex-col px-2 py-0.5 text-left hover:bg-base-100/50",
{"mt-1.5": showPubkey, "mb-1.5": addSpaceBelow},
)}>
<div class="flex w-full gap-3 overflow-auto">
{#if showPubkey}
<Button onclick={openProfile} class="flex items-start">
<Button onclick={openProfile} class="flex items-start pt-1.5 justify-center w-8 shrink-0">
<ProfileCircle
pubkey={event.pubkey}
class="border border-solid border-base-content"
size={8} />
</Button>
{:else}
<div class="w-8 min-w-8 max-w-8"></div>
<div class="w-8 shrink-0"></div>
{/if}
<div class="min-w-0 flex-grow pr-1">
{#if showPubkey}
@@ -140,7 +145,7 @@
</div>
{#if !isMobile}
<button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class="join absolute right-2 top-0.5 border border-solid border-neutral text-xs opacity-0 transition-all pr-2"
class:group-hover:opacity-100={!isMobile}>
{#if ENABLE_ZAPS}
<RoomItemZapButton {url} {event} />
@@ -1,18 +0,0 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getPubkeyTagValues} from "@welshman/util"
import ProfileLink from "@app/components/ProfileLink.svelte"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
</script>
{#each getPubkeyTagValues(event.tags) as pubkey}
<div class="py-1 text-center text-xs opacity-75">
<ProfileLink unstyled class="text-primary" {url} {pubkey} /> left the room
</div>
{/each}
+13 -2
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import {derived} from "svelte/store"
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
import {deriveRelay, createSearch, pubkey} from "@welshman/app"
import {Poll} from "nostr-tools/kinds"
import {deriveRelay, deriveRelayDisplay, createSearch, pubkey} from "@welshman/app"
import {fly} from "@lib/transition"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
@@ -17,6 +18,7 @@
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import Revote from "@assets/icons/revote.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
@@ -64,11 +66,13 @@
const {url} = $props()
const relay = deriveRelay(url)
const display = deriveRelayDisplay(url)
const chatPath = makeSpacePath(url, "chat")
const goalsPath = makeSpacePath(url, "goals")
const threadsPath = makeSpacePath(url, "threads")
const classifiedsPath = makeSpacePath(url, "classifieds")
const calendarPath = makeSpacePath(url, "calendar")
const pollsPath = makeSpacePath(url, "polls")
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const otherVoiceRooms = deriveOtherVoiceRooms(url)
@@ -141,7 +145,9 @@
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
onclick={openMenu}>
<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" />
<div
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
@@ -257,6 +263,11 @@
<Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(Poll)}
<SecondaryNavItem href={pollsPath}>
<Icon icon={Revote} /> Polls
</SecondaryNavItem>
{/if}
{#if hasNip29($relay)}
{#if $userRooms.length > 0}
<div class="h-2 flex-shrink-0"></div>
+2 -1
View File
@@ -24,12 +24,13 @@
const shouldNotifyForRoom = deriveShouldNotify(url, h)
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false)
const roomName = $derived($room?.name || h)
</script>
{#if roomType === RoomType.Voice}
<VoiceRoomItem {url} {h} {replaceState} {notification} />
{:else}
<SecondaryNavItem href={path} {replaceState} {notification}>
<SecondaryNavItem href={path} title={roomName} {replaceState} {notification}>
<RoomNameWithImage {url} {h} />
{#if showDifferenceIcon}
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
+121 -85
View File
@@ -1,15 +1,16 @@
<script lang="ts">
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 type {TrustedEvent} from "@welshman/util"
import {MESSAGE} from "@welshman/util"
import type {TrustedEvent, Filter} from "@welshman/util"
import {sortEventsDesc} from "@welshman/util"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import {fly} from "@lib/transition"
import Button from "@lib/components/Button.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"
type Props = {
@@ -19,14 +20,16 @@
const {url, h}: Props = $props()
const spaceMessages = deriveEventsForUrl(
url,
h ? [{kinds: [MESSAGE], "#h": [h]}] : [{kinds: [MESSAGE]}],
)
let term = $state("")
let show = $state(false)
let results = $state<TrustedEvent[]>([])
let loading = $state(false)
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 = () => {
show = true
@@ -40,21 +43,53 @@
const clear = () => {
term = ""
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 = () => {
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 getAgeSection = (createdAt: number) => {
@@ -95,73 +130,74 @@
}
</script>
<div>
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
<Icon size={4} icon={Magnifier} />
</button>
{#if show}
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
<div class="fixed cw top-0 right-0 z-feature p-2">
<div
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
transition:fly={{y: -40, duration: 150}}>
<div class="flex justify-between">
<strong>Search</strong>
<Button onclick={clear}>
<Icon icon={CloseCircle} />
</Button>
</div>
<label class="input input-sm input-bordered flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input
bind:this={input}
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder={h ? "Search this room..." : "Search this space..."}
oninput={onInput} />
</label>
<div class="max-h-[65vh] overflow-y-auto">
{#if !term}
<p class="text-sm opacity-70">
{h ? "Search for messages in this room." : "Search for messages across this space."}
</p>
{:else if eventsByAge.size === 0}
<p class="text-sm opacity-70">No results found.</p>
{:else}
<div class="col-2">
{#each eventsByAge as [key, events] (key)}
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
<Icon size={4} icon={Magnifier} />
</button>
{#if show}
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
<div class="fixed top-sai right-sai left-content z-feature p-2">
<div
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
transition:fly={{y: -40, duration: 150}}>
<div class="flex justify-between">
<strong>Search</strong>
<Button onclick={clear}>
<Icon icon={CloseCircle} />
</Button>
</div>
<label class="input input-sm input-bordered flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input
bind:this={input}
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder={h ? "Search this room..." : "Search this space..."}
oninput={onInput} />
</label>
<div class="max-h-[65vh] overflow-y-auto">
<p class="mb-2 text-xs opacity-70">{relayStatus}</p>
{#if !term}
<p class="text-sm opacity-70">
{h ? "Search for content in this room." : "Search for content in this space."}
</p>
{:else if loading}
<p class="text-sm opacity-70">Searching...</p>
{:else if eventsByAge.size === 0}
<p class="text-sm opacity-70">No results found.</p>
{:else}
<div class="col-2">
{#each eventsByAge as [key, events] (key)}
<div class="col-2">
<p class="text-xs uppercase tracking-wide opacity-60">
{#if key === "day"}
Last 24 Hours
{:else if key === "week"}
Last 7 Days
{:else}
Older
{/if}
</p>
<div class="col-2">
<p class="text-xs uppercase tracking-wide opacity-60">
{#if key === "day"}
Last 24 Hours
{:else if key === "week"}
Last 7 Days
{:else}
Older
{/if}
</p>
<div class="col-2">
{#each events as event (event.id)}
<button
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
onclick={() => onRoomSearchResultClick(event)}>
<p class="line-clamp-2 text-sm">
{event.content.trim() || "(No text content)"}
</p>
<div class="row-2 text-xs opacity-70">
<span>{getAgeLabel(event.created_at)}</span>
<span>{formatTimestampAsDate(event.created_at)}</span>
</div>
</button>
{/each}
</div>
{#each events as event (event.id)}
<button
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
onclick={() => onRoomSearchResultClick(event)}>
<p class="line-clamp-2 text-sm">
{event.content.trim() || "(No text content)"}
</p>
<div class="row-2 text-xs opacity-70">
<span>{getAgeLabel(event.created_at)}</span>
<span>{formatTimestampAsDate(event.created_at)}</span>
</div>
</button>
{/each}
</div>
{/each}
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}
+27 -3
View File
@@ -18,15 +18,22 @@
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70} from "@app/core/commands"
type Values = {
content?: string | object
title?: string
}
type Props = {
url: string
h?: string
}
const {url, h}: Props = $props()
const draftKey = new DraftKey<Values>(`thread:${url}:${h ?? ""}`)
const initialValues = draftKey.get()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
@@ -70,12 +77,29 @@
event: makeEvent(THREAD, {content, tags}),
})
draftKey.clear()
history.back()
}
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
let title = $state(initialValues?.title ?? "")
let content = $state(initialValues?.content ?? "")
let title: string = $state("")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
submit,
uploading,
onChange,
placeholder: "What's on your mind?",
content,
})
$effect(() => {
draftKey.update({title, content})
})
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
+74 -7
View File
@@ -1,28 +1,98 @@
<script lang="ts">
import {parse, renderAsHtml} from "@welshman/content"
import Close from "@assets/icons/close.svg?dataurl"
import {fly} from "@lib/transition"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {toast, popToast} from "@app/util/toast"
let touchStartY = 0
let touchStartTime = 0
let dragY = $state(0)
let isSettling = $state(false)
let containerEl = $state<HTMLDivElement | undefined>(undefined)
$effect(() => {
if ($toast) {
dragY = 0
isSettling = false
}
})
$effect(() => {
if (!containerEl) return
containerEl.addEventListener("touchmove", onTouchMove, {passive: false})
return () => containerEl!.removeEventListener("touchmove", onTouchMove)
})
const onActionClick = () => {
$toast!.action!.onclick()
popToast($toast!.id)
}
const onClose = () => popToast($toast!.id)
const onTouchStart = (e: TouchEvent) => {
touchStartY = e.touches[0].clientY
touchStartTime = Date.now()
dragY = 0
isSettling = false
}
const onTouchMove = (e: TouchEvent) => {
const delta = e.touches[0].clientY - touchStartY
if (delta < 0) {
e.preventDefault()
isSettling = false
dragY = delta
} else {
dragY = 0
}
}
const onTouchEnd = (e: TouchEvent) => {
const delta = e.changedTouches[0].clientY - touchStartY
const duration = Date.now() - touchStartTime
const isQuickFlick = duration < 400 && delta < 0
const isSlowDismiss = delta < -40
if (isQuickFlick || isSlowDismiss) {
dragY = 0
popToast($toast!.id)
} else {
isSettling = true
dragY = 0
setTimeout(() => {
isSettling = false
}, 200)
}
}
</script>
{#if $toast}
{@const theme = $toast.theme || "info"}
<div transition:fly class="bottom-sai right-sai toast z-toast">
<div
bind:this={containerEl}
transition:fly={{y: -20}}
class="fixed z-toast top-[calc(var(--sait)+0.5rem)] left-[calc(var(--sail)+0.5rem)] right-[calc(var(--sair)+0.5rem)] flex flex-col gap-2 md:right-4 md:bottom-4 md:top-auto md:left-auto md:w-80"
style={dragY !== 0 || isSettling
? `transform: translateY(${dragY}px)${isSettling ? "; transition: transform 200ms ease-out" : ""}`
: ""}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}>
{#key $toast.id}
<div
role="alert"
class="alert flex justify-center whitespace-normal text-left"
class="alert relative flex justify-center whitespace-normal text-left"
class:bg-base-100={theme === "info"}
class:text-base-content={theme === "info"}
class:alert-error={theme === "error"}>
<p class:welshman-content-error={theme === "error"}>
<Button
class="absolute -top-2 -right-2 btn btn-circle btn-neutral btn-xs hidden md:inline-flex flex justify-center items-center"
onclick={onClose}>
<Icon icon={Close} size={4} />
</Button>
<p class="md:pr-6" class:welshman-content-error={theme === "error"}>
{#if $toast.message}
{@html renderAsHtml(parse({content: $toast.message}))}
{#if $toast.action}
@@ -35,9 +105,6 @@
<Component toast={$toast} {...props} />
{/if}
</p>
<Button class="flex items-center opacity-75" onclick={() => popToast($toast.id)}>
<Icon icon={CloseCircle} />
</Button>
</div>
{/key}
</div>
@@ -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>
+13 -1
View File
@@ -12,9 +12,11 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {AbortError, TimeoutError} from "$lib/util"
import {displayRoom} from "@app/core/state"
import {joinVoiceRoom} from "@app/voice"
import {popModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
type Props = {
url: string
@@ -45,6 +47,16 @@
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 () => {
popModal()
await joinVoiceRoom(
@@ -52,7 +64,7 @@
h,
startWithoutMic,
startWithoutMic ? undefined : selectedDeviceId || undefined,
)
).catch(handleJoinError)
}
</script>
+12
View File
@@ -9,8 +9,10 @@
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.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 Button from "@lib/components/Button.svelte"
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
import {
decodeRelay,
@@ -63,6 +65,10 @@
await goto(makeRoomPath(targetRoom.url, targetRoom.h))
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
}
const openAudioSettings = () => {
pushModal(VoiceCallAudioSettingsDialog)
}
</script>
{#if targetRoom}
@@ -100,6 +106,12 @@
onclick={toggleMute}>
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
</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
data-tip="Leave room"
class="center tooltip tooltip-top btn btn-sm btn-square btn-error"
+17
View File
@@ -18,6 +18,7 @@ import {
import {Nip01Signer} from "@welshman/signer"
import type {UploadTask} from "@welshman/editor"
import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util"
import {PollResponse} from "nostr-tools/kinds"
import {
DELETE,
REPORT,
@@ -351,6 +352,22 @@ export const publishReaction = ({relays, ...params}: ReactionParams & {relays: s
publishThunk({event: makeReaction({url: relays[0], ...params}), relays})
}
// Polls
export type PollResponseParams = {
event: TrustedEvent
selectedIds: string[]
}
export const makePollResponse = ({event, selectedIds}: PollResponseParams) =>
makeEvent(PollResponse, {
content: "",
tags: [["e", event.id], ...selectedIds.map(selectedId => ["response", selectedId])],
})
export const publishPollResponse = ({relays, ...params}: PollResponseParams & {relays: string[]}) =>
publishThunk({event: makePollResponse(params), relays})
// Comments
export type CommentParams = {
+5 -2
View File
@@ -3,6 +3,7 @@ import {context as pomadeContext} from "@pomade/core"
import {Capacitor} from "@capacitor/core"
import {derived, readable, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import {Poll} from "nostr-tools/kinds"
import {
on,
gt,
@@ -191,7 +192,9 @@ export const PLATFORM_TERMS = import.meta.env.VITE_PLATFORM_TERMS
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
@@ -323,7 +326,7 @@ if (ENABLE_ZAPS) {
REACTION_KINDS.push(ZAP_RESPONSE)
}
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED]
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, Poll]
export const MESSAGE_KINDS = [...CONTENT_KINDS, MESSAGE]
+5 -5
View File
@@ -2,6 +2,7 @@ import {page} from "$app/stores"
import type {Unsubscriber} from "svelte/store"
import {derived, get} from "svelte/store"
import {last, call, ifLet, assoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
import {PollResponse} from "nostr-tools/kinds"
import {
getListTags,
getRelayTagValues,
@@ -281,6 +282,7 @@ const syncSpace = (url: string, rooms: string[]) => {
filters: [
{kinds: MESSAGE_KINDS, since, "#h": [room]},
makeCommentFilter(CONTENT_KINDS, {since, "#h": [room]}),
{kinds: [PollResponse], since},
],
})
}
@@ -298,11 +300,9 @@ const syncSpace = (url: string, rooms: string[]) => {
url,
signal: controller.signal,
filters: [
{kinds: relayKinds},
{kinds: roomMetaKinds},
{kinds: roomMemberKinds},
{kinds: MESSAGE_KINDS, since},
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...MESSAGE_KINDS]},
makeCommentFilter(CONTENT_KINDS, {since}),
{kinds: [PollResponse], since},
],
onEvent: event => {
if (event.kind === ROOM_META) {
@@ -314,7 +314,7 @@ const syncSpace = (url: string, rooms: string[]) => {
listen({
url,
signal: controller.signal,
filters: [{kinds: REACTION_KINDS}],
filters: [{kinds: REACTION_KINDS}, {kinds: [PollResponse]}],
})
return () => controller.abort()
+11 -6
View File
@@ -4,20 +4,25 @@
type Props = {
editor: Promise<Editor>
autofocus?: boolean
}
const {editor}: Props = $props()
const {editor, autofocus}: Props = $props()
let element: HTMLElement
onMount(() => {
editor.then(({options}) => {
if (options.element) {
element?.append(options.element)
editor.then(ed => {
if (ed.options.element) {
element?.append(ed.options.element)
}
if (options.autofocus) {
;(element?.querySelector("[contenteditable]") as HTMLElement)?.focus()
if (autofocus) {
const hasContent = ed.getText().trim().length > 0
requestAnimationFrame(() => {
ed.commands.focus(hasContent ? "end" : "start")
})
}
})
})
+8 -6
View File
@@ -25,9 +25,9 @@ import {pushToast} from "@app/util/toast"
export const makeEditor = async ({
encryptFiles = false,
aggressive = false,
autofocus = false,
charCount,
content = "",
onChange,
placeholder = "",
url,
submit,
@@ -36,9 +36,9 @@ export const makeEditor = async ({
}: {
encryptFiles?: boolean
aggressive?: boolean
autofocus?: boolean
charCount?: Writable<number>
content?: string
content?: string | object
onChange?: (json: object) => void
placeholder?: string
url?: string
submit: () => void
@@ -82,9 +82,8 @@ export const makeEditor = async ({
},
)
return new Editor({
content: escapeHtml(content),
autofocus,
const ed = new Editor({
content: typeof content === "string" ? escapeHtml(content) : content,
editorProps,
element: document.createElement("div"),
extensions: [
@@ -142,6 +141,9 @@ export const makeEditor = async ({
onUpdate({editor}) {
wordCount?.set(editor.storage.wordCount.words)
charCount?.set(editor.storage.wordCount.chars)
onChange?.(editor.getJSON())
},
})
return ed
}
+21
View File
@@ -0,0 +1,21 @@
const store = new Map<string, unknown>()
export class DraftKey<T> {
constructor(private key: string) {}
get(): T | undefined {
return store.get(this.key) as T | undefined
}
set(value: T): void {
store.set(this.key, value)
}
update(value: Partial<T>): void {
this.set({...this.get(), ...value} as T)
}
clear(): void {
store.delete(this.key)
}
}
+76
View File
@@ -0,0 +1,76 @@
import {now, removeUndefined, uniq} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getTagValue, getTags, getTagValues} from "@welshman/util"
export type PollType = "singlechoice" | "multiplechoice"
export type PollOption = {
id: string
label: string
votes: number
}
export const getPollType = (event: TrustedEvent): PollType =>
getTagValue("polltype", event.tags) === "multiplechoice" ? "multiplechoice" : "singlechoice"
export const getPollOptions = (event: TrustedEvent) =>
removeUndefined(
getTags("option", event.tags).map(tag => {
const [, id, label = id] = tag
if (!id) return undefined
return {id, label}
}),
)
export const getPollEndsAt = (event: TrustedEvent) => {
const endsAt = getTagValue("endsAt", event.tags)
if (!endsAt) return undefined
const timestamp = parseInt(endsAt)
return Number.isNaN(timestamp) ? undefined : timestamp
}
export const isPollClosed = (event: TrustedEvent) => {
const endsAt = getPollEndsAt(event)
return typeof endsAt === "number" ? endsAt <= now() : false
}
export const getPollResponseSelections = (event: TrustedEvent, pollType = getPollType(event)) => {
const selections = getTagValues("response", event.tags)
return pollType === "singlechoice" ? selections.slice(0, 1) : uniq(selections)
}
export const getPollResults = (event: TrustedEvent, responses: TrustedEvent[]) => {
const options = getPollOptions(event).map(option => ({...option, votes: 0}))
const counts = new Map(options.map(option => [option.id, option]))
const latestByPubkey = new Map<string, TrustedEvent>()
for (const response of responses) {
const current = latestByPubkey.get(response.pubkey)
if (!current || response.created_at > current.created_at) {
latestByPubkey.set(response.pubkey, response)
}
}
for (const response of latestByPubkey.values()) {
for (const optionId of getPollResponseSelections(response, getPollType(event))) {
const option = counts.get(optionId)
if (option) {
option.votes += 1
}
}
}
return {
options,
voters: latestByPubkey.size,
}
}
+9
View File
@@ -6,6 +6,7 @@ import {page} from "$app/stores"
import {nthEq} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getAddress} from "@welshman/util"
import {Poll} from "nostr-tools/kinds"
import {tracker, userMessagingRelayList} from "@welshman/app"
import {identity} from "@welshman/lib"
import {
@@ -90,6 +91,8 @@ export const makeClassifiedPath = (url: string, address?: string) =>
export const makeCalendarPath = (url: string, address?: string) =>
makeSpacePath(url, "calendar", address)
export const makePollPath = (url: string, id?: string) => makeSpacePath(url, "polls", id)
export const scrollToEvent = (id: string) => {
const element = document.querySelector(`[data-event="${id}"]`) as any
@@ -146,6 +149,10 @@ export const getEventPath = (event: TrustedEvent, urls: string[]) => {
return makeCalendarPath(url, getAddress(event))
}
if (event.kind === Poll) {
return makePollPath(url, event.id)
}
if (event.kind === MESSAGE) {
return makeMessagePath(url, event)
}
@@ -192,5 +199,7 @@ export const getRoomItemPath = (url: string, event: TrustedEvent) => {
return makeGoalPath(url, event.id)
case EVENT_TIME:
return makeCalendarPath(url, getAddress(event))
case Poll:
return makePollPath(url, event.id)
}
}
+2
View File
@@ -17,6 +17,7 @@ const staticTitles = new Map<string, string>([
["/spaces/[relay]/classifieds", "Classifieds"],
["/spaces/[relay]/calendar", "Calendar"],
["/spaces/[relay]/goals", "Goals"],
["/spaces/[relay]/polls", "Polls"],
["/chat", "Messages"],
["/join", "Join Space"],
["/people", "Find People"],
@@ -35,6 +36,7 @@ const eventRoutes = new Set([
"/spaces/[relay]/goals/[id]",
"/spaces/[relay]/calendar/[address]",
"/spaces/[relay]/classifieds/[address]",
"/spaces/[relay]/polls/[id]",
])
type RouteParams = Record<string, string | undefined>
+33
View File
@@ -7,6 +7,7 @@ import {
Room as LiveKitRoom,
RoomEvent,
Track,
supportsAudioOutputSelection,
type AudioCaptureOptions,
type LocalParticipant,
} from "livekit-client"
@@ -24,6 +25,8 @@ export const LIVEKIT_PARTICIPANTS = 39004
export {checkRelayHasLivekit} from "$lib/livekit"
export {supportsAudioOutputSelection}
export type VoiceSession = {
url: string
h: string
@@ -43,6 +46,36 @@ export enum VoiceState {
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 currentVoiceRoom = writable<Room | undefined>(undefined)
+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.0867 21.3879L13.7321 21.77L13.0867 21.3879ZM13.6288 20.4721L12.9833 20.0901L13.6288 20.4721ZM10.3712 20.4721L9.72579 20.8541H9.72579L10.3712 20.4721ZM10.9133 21.3879L11.5587 21.0059L10.9133 21.3879ZM2.3806 15.9137L3.07351 15.6266V15.6266L2.3806 15.9137ZM7.78958 18.9917L7.77666 19.7416L7.78958 18.9917ZM5.08658 18.6196L4.79957 19.3126H4.79957L5.08658 18.6196ZM21.6194 15.9137L22.3123 16.2007V16.2007L21.6194 15.9137ZM16.2104 18.9917L16.1975 18.2418L16.2104 18.9917ZM18.9134 18.6196L19.2004 19.3126H19.2004L18.9134 18.6196ZM19.6125 2.73704L19.2206 3.37652L19.6125 2.73704ZM21.2632 4.38775L21.9027 3.99588V3.99588L21.2632 4.38775ZM4.38751 2.73704L3.99563 2.09756V2.09756L4.38751 2.73704ZM2.7368 4.38775L2.09732 3.99588H2.09732L2.7368 4.38775ZM9.40279 19.2101L9.77986 18.5618L9.77986 18.5618L9.40279 19.2101ZM13.7321 21.77L14.2742 20.8541L12.9833 20.0901L12.4412 21.0059L13.7321 21.77ZM9.72579 20.8541L10.2679 21.77L11.5587 21.0059L11.0166 20.0901L9.72579 20.8541ZM12.4412 21.0059C12.2485 21.3316 11.7515 21.3316 11.5587 21.0059L10.2679 21.77C11.0415 23.0769 12.9585 23.0769 13.7321 21.77L12.4412 21.0059ZM10.5 2.75024H13.5V1.25024H10.5V2.75024ZM21.25 10.5002V11.5002H22.75V10.5002H21.25ZM2.75 11.5002V10.5002H1.25V11.5002H2.75ZM1.25 11.5002C1.25 12.6548 1.24959 13.5583 1.29931 14.2871C1.3495 15.0225 1.45323 15.6346 1.68769 16.2007L3.07351 15.6266C2.92737 15.2738 2.84081 14.8441 2.79584 14.185C2.75041 13.5191 2.75 12.6754 2.75 11.5002H1.25ZM7.8025 18.2418C6.54706 18.2202 5.88923 18.1403 5.37359 17.9267L4.79957 19.3126C5.60454 19.646 6.52138 19.72 7.77666 19.7416L7.8025 18.2418ZM1.68769 16.2007C2.27128 17.6096 3.39066 18.729 4.79957 19.3126L5.3736 17.9267C4.33223 17.4954 3.50486 16.668 3.07351 15.6266L1.68769 16.2007ZM21.25 11.5002C21.25 12.6754 21.2496 13.5191 21.2042 14.185C21.1592 14.8441 21.0726 15.2738 20.9265 15.6266L22.3123 16.2007C22.5468 15.6346 22.6505 15.0225 22.7007 14.2871C22.7504 13.5583 22.75 12.6548 22.75 11.5002H21.25ZM16.2233 19.7416C17.4786 19.72 18.3955 19.646 19.2004 19.3126L18.6264 17.9267C18.1108 18.1403 17.4529 18.2202 16.1975 18.2418L16.2233 19.7416ZM20.9265 15.6266C20.4951 16.668 19.6678 17.4954 18.6264 17.9267L19.2004 19.3126C20.6093 18.729 21.7287 17.6096 22.3123 16.2007L20.9265 15.6266ZM13.5 2.75024C15.1512 2.75024 16.337 2.75104 17.2619 2.83898C18.1757 2.92586 18.7571 3.09247 19.2206 3.37652L20.0044 2.09756C19.2655 1.64481 18.4274 1.44303 17.4039 1.34571C16.3915 1.24945 15.1222 1.25024 13.5 1.25024V2.75024ZM22.75 10.5002C22.75 8.87805 22.7508 7.60874 22.6545 6.59635C22.5572 5.5728 22.3554 4.7347 21.9027 3.99588L20.6237 4.77962C20.9078 5.24315 21.0744 5.82458 21.1613 6.73833C21.2492 7.66325 21.25 8.84901 21.25 10.5002H22.75ZM19.2206 3.37652C19.7925 3.72696 20.2733 4.20776 20.6237 4.77963L21.9027 3.99588C21.4286 3.22218 20.7781 2.57168 20.0044 2.09756L19.2206 3.37652ZM10.5 1.25024C8.87781 1.25024 7.6085 1.24945 6.59611 1.34571C5.57256 1.44303 4.73445 1.64481 3.99563 2.09756L4.77938 3.37652C5.24291 3.09247 5.82434 2.92586 6.73809 2.83898C7.663 2.75104 8.84876 2.75024 10.5 2.75024V1.25024ZM2.75 10.5002C2.75 8.84901 2.75079 7.66325 2.83873 6.73833C2.92561 5.82458 3.09223 5.24315 3.37628 4.77963L2.09732 3.99588C1.64457 4.7347 1.44279 5.5728 1.34547 6.59635C1.24921 7.60874 1.25 8.87805 1.25 10.5002H2.75ZM3.99563 2.09756C3.22194 2.57168 2.57144 3.22218 2.09732 3.99588L3.37628 4.77963C3.72672 4.20776 4.20752 3.72696 4.77938 3.37652L3.99563 2.09756ZM11.0166 20.0901C10.8136 19.747 10.6354 19.4444 10.4621 19.2066C10.2795 18.9562 10.0702 18.7306 9.77986 18.5618L9.02572 19.8584C9.07313 19.886 9.13772 19.9362 9.24985 20.0901C9.37122 20.2566 9.50835 20.4867 9.72579 20.8541L11.0166 20.0901ZM7.77666 19.7416C8.21575 19.7492 8.49387 19.7547 8.70588 19.7782C8.90399 19.8001 8.98078 19.8323 9.02572 19.8584L9.77986 18.5618C9.4871 18.3915 9.18246 18.3218 8.87097 18.2873C8.57339 18.2543 8.21375 18.2489 7.8025 18.2418L7.77666 19.7416ZM14.2742 20.8541C14.4916 20.4867 14.6287 20.2566 14.7501 20.0901C14.8622 19.9362 14.9268 19.886 14.9742 19.8584L14.2201 18.5618C13.9298 18.7306 13.7204 18.9562 13.5379 19.2066C13.3646 19.4444 13.1864 19.747 12.9833 20.0901L14.2742 20.8541ZM16.1975 18.2418C15.7862 18.2489 15.4266 18.2543 15.129 18.2873C14.8175 18.3218 14.5129 18.3915 14.2201 18.5618L14.9742 19.8584C15.0192 19.8323 15.096 19.8001 15.2941 19.7782C15.5061 19.7547 15.7842 19.7492 16.2233 19.7416L16.1975 18.2418Z" fill="#000000"/>
<path d="M12 7.5V14.5M8.5 11H15.5" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import type {Snippet} from "svelte"
import cx from "classnames"
import Button from "@lib/components/Button.svelte"
const {
onclick = () => {},
className = "",
children,
}: {
onclick?: () => void
className?: string
children?: Snippet
} = $props()
</script>
<div class={cx("fixed bottom-20 right-4 z-nav hide-on-keyboard md:hidden", className)}>
<Button
class="btn btn-primary border-none shadow-xl hover:opacity-90 transition-all size-[50px] rounded-xl p-0"
{onclick}>
<div class="flex items-center justify-center">
{@render children?.()}
</div>
</Button>
</div>
+1 -1
View File
@@ -9,6 +9,6 @@
<div
data-component="Page"
class="scroll-container bottom-sai top-sai cw fixed mb-14 overflow-auto bg-base-200 md:mb-0 {props.class}">
class="relative flex-grow flex flex-col min-w-0 ml-sai mb-sai mt-sai mr-sai bg-base-200 md:ml-0 md:mb-0 {props.class}">
{@render props.children?.()}
</div>
+1 -1
View File
@@ -9,7 +9,7 @@
const {children, ...props}: Props = $props()
</script>
<div data-component="PageBar" class="cw top-sai fixed z-nav p-2 {props.class}">
<div data-component="PageBar" class="relative z-nav p-2 -mb-4 {props.class}">
<div class="rounded-xl bg-base-100 p-4 shadow-md h-20 md:h-12 flex flex-col justify-center">
{@render children?.()}
</div>
+1 -4
View File
@@ -10,10 +10,7 @@
let {children, element = $bindable(), ...props}: Props = $props()
const className = cx(
props.class,
"scroll-container cw cb ct fixed z-feature overflow-y-auto overflow-x-hidden",
)
const className = cx(props.class, "scroll-container z-feature overflow-y-auto overflow-x-hidden")
</script>
<div {...props} bind:this={element} data-component="PageContent" class={className}>
+23 -19
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import cx from "classnames"
import {page} from "$app/stores"
import Button from "@lib/components/Button.svelte"
@@ -13,32 +14,35 @@
} = $props()
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>
{#if onclick}
<Button {onclick} 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}>
<div class={wrapperClass} data-tip={title}>
{#if onclick}
<Button {onclick} class={innerClass}>
{@render children?.()}
{#if !active && notification}
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div>
{/if}
</div>
</Button>
{:else}
<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}>
</Button>
{:else}
<a {href} class={innerClass}>
{@render children?.()}
{#if !active && notification}
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div>
{/if}
</div>
</a>
{/if}
</a>
{/if}
</div>
+1 -1
View File
@@ -12,7 +12,7 @@
<div
class={cx(
"ml-sai mt-sai mb-sai max-h-screen w-60 sm:flex-shrink-0 flex-col gap-1 bg-base-300 z-nav hidden md:flex",
"mt-sai mb-sai max-h-screen w-60 min-h-0 flex-shrink-0 flex-col gap-1 bg-base-300 z-nav hidden md:flex",
props.class,
)}>
{@render children?.()}
+1 -1
View File
@@ -6,6 +6,6 @@
const {children}: Props = $props()
</script>
<div class="flex items-center justify-between px-4 py-2 text-sm font-bold uppercase">
<div class="flex items-center justify-between px-1 py-2 text-sm font-bold uppercase">
{@render children?.()}
</div>
+19 -9
View File
@@ -21,22 +21,36 @@
</style>
<script lang="ts">
import cx from "classnames"
import {fade} from "@lib/transition"
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 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>
{#if href}
<a
{href}
{...restProps}
data-tip={title}
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}>
class={wrapperClass}>
{@render children?.()}
{#if notification}
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade>
@@ -44,11 +58,7 @@
{/if}
</a>
{:else}
<button
{...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}>
<button {...restProps} data-tip={title} class={wrapperClass}>
{#if notification}
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade>
</div>
@@ -7,6 +7,6 @@
const {...props}: Props = $props()
</script>
<div class="flex flex-col gap-1 px-2 py-4 {props.class}">
<div class="flex flex-col gap-3 px-2 py-2 {props.class}">
{@render props.children?.()}
</div>
+21 -5
View File
@@ -5,15 +5,19 @@
import {sleep} from "@welshman/lib"
import {shouldUnwrap} from "@welshman/app"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import ChatSquarePlus from "@assets/icons/chat-square-plus.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Page from "@lib/components/Page.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import FAB from "@lib/components/FAB.svelte"
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
import ChatMenu from "@app/components/ChatMenu.svelte"
import ChatStart from "@app/components/ChatStart.svelte"
import ChatItem from "@app/components/ChatItem.svelte"
import {chatSearch} from "@app/core/state"
import {pushModal} from "@app/util/modal"
@@ -26,6 +30,8 @@
const openMenu = () => pushModal(ChatMenu)
const startChat = () => pushModal(ChatStart)
let term = $state("")
const chats = $derived($chatSearch.searchOptions(term))
@@ -37,7 +43,7 @@
})
</script>
<SecondaryNav>
<SecondaryNav class="relative">
<SecondaryNavSection>
<SecondaryNavHeader>
Chats
@@ -45,11 +51,15 @@
<Icon icon={MenuDots} />
</Button>
</SecondaryNavHeader>
<Button class="btn btn-primary w-full row-2 min-h-0 h-[30px]" onclick={startChat}>
<Icon icon={AddCircle} />
Start New Chat
</Button>
<label class="input input-sm input-bordered flex items-center gap-2">
<Icon icon={Magnifier} />
<input bind:value={term} class="grow" type="text" />
</label>
</SecondaryNavSection>
<label class="input input-sm input-bordered mx-6 -mt-4 mb-2 flex items-center gap-2">
<Icon icon={Magnifier} />
<input bind:value={term} class="grow" type="text" />
</label>
<div class="overflow-auto">
{#each chats as { id, pubkeys, messages } (id)}
<ChatItem {id} {pubkeys} {messages} />
@@ -66,3 +76,9 @@
{@render children?.()}
{/key}
</Page>
{#if !$page.params.chat}
<FAB onclick={startChat}>
<Icon icon={ChatSquarePlus} size={7} />
</FAB>
{/if}
+1 -1
View File
@@ -37,7 +37,7 @@
})
</script>
<Page class="cw-full">
<Page>
<ContentSearch>
{#snippet input()}
<label class="row-2 input input-bordered">
+1 -1
View File
@@ -84,7 +84,7 @@
</div>
</div>
</div>
<Button class="center btn btn-circle btn-neutral -mr-4 -mt-4 h-12 w-12" onclick={startEdit}>
<Button class="center btn btn-circle btn-neutral -mr-2 -mt-2 h-12 w-12" onclick={startEdit}>
<Icon icon={PenNewSquare} />
</Button>
</div>
+9 -5
View File
@@ -31,7 +31,8 @@
} from "@app/core/state"
import {setSpaceMembershipOrder} from "@app/core/commands"
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)
@@ -179,8 +180,8 @@
})
</script>
<Page class="cw-full">
<PageBar class="cw-full">
<Page>
<PageBar>
{#if showSearch}
<label class="input input-bordered input-sm flex flex-1 items-center gap-2" in:fly>
<Icon icon={Magnifier} />
@@ -217,7 +218,7 @@
</div>
{/if}
</PageBar>
<PageContent class="cw-full flex flex-col gap-2 p-2 pt-4">
<PageContent class="flex flex-col gap-2 p-2 pt-4">
<div class="flex flex-col gap-2" bind:this={element}>
{#each PLATFORM_RELAYS as url (url)}
<Button
@@ -254,9 +255,12 @@
ondrop={e => onDrop(e, url)}
ondragend={onDragEnd}>
<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)}>
<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>
</div>
{/each}
+2 -2
View File
@@ -22,10 +22,10 @@
<svelte:window bind:innerWidth={width} />
{#if width <= md}
<div class="ml-sai mt-sai mb-sai relative z-nav w-14 flex-shrink-0 bg-base-200 pt-4">
<div class="ml-sai mt-sai mb-sai relative z-nav w-14 flex-shrink-0 bg-base-200 pt-2">
<PrimaryNavSpaces />
</div>
<SecondaryNav class="!flex !min-h-0 !w-auto flex-grow pb-4">
<SecondaryNav class="!flex !w-auto flex-grow pb-16">
<SpaceMenu {url} />
</SecondaryNav>
{/if}
+23 -38
View File
@@ -8,13 +8,7 @@
import {now, ifLet, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {
makeEvent,
makeRoomMeta,
MESSAGE,
ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER,
} from "@welshman/util"
import {makeEvent, makeRoomMeta, MESSAGE, ROOM_ADD_MEMBER} from "@welshman/util"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
@@ -36,7 +30,6 @@
import ThunkToast from "@app/components/ThunkToast.svelte"
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
import {canEnforceNip70, prependParent, publishDelete} from "@app/core/commands"
import {
decodeRelay,
@@ -232,8 +225,6 @@
let share = $state(popKey<TrustedEvent | undefined>("share"))
let parent: TrustedEvent | undefined = $state()
let element: HTMLElement | undefined = $state()
let chatCompose: HTMLElement | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state()
let newMessagesSeen = false
let showFixedNewMessages = $state(false)
let showScrollButton = $state(false)
@@ -281,14 +272,21 @@
elements.push({type: "date", value: date, id: date, showPubkey: false})
}
const showPubkey =
previousPubkey !== event.pubkey ||
event.created_at - previousCreatedAt > int(3, MINUTE) ||
previousKind === ROOM_ADD_MEMBER
if (showPubkey && elements.length > 0) {
elements[elements.length - 1].addSpaceBelow = true
}
elements.push({
id: event.id,
type: "note",
value: event,
showPubkey:
previousPubkey !== event.pubkey ||
event.created_at - previousCreatedAt > int(3, MINUTE) ||
[ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER].includes(previousKind!),
showPubkey,
addSpaceBelow: false,
})
previousDate = date
@@ -297,6 +295,9 @@
previousCreatedAt = event.created_at
seen.add(event.id)
}
if (elements.length > 0) {
elements[elements.length - 1].addSpaceBelow = true
}
}
elements.reverse()
@@ -313,7 +314,7 @@
url,
at: at || now(),
element: element!,
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}],
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER], "#h": [h]}],
onBackwardExhausted: () => {
loadingBackward = false
},
@@ -344,22 +345,9 @@
const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent)
onMount(() => {
const observer = new ResizeObserver(() => {
if (dynamicPadding && chatCompose) {
dynamicPadding!.style.minHeight = `${chatCompose!.offsetHeight}px`
}
})
observer.observe(chatCompose!)
observer.observe(dynamicPadding!)
start()
return () => {
cleanup()
observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
}
return cleanup
})
</script>
@@ -377,7 +365,6 @@
</SpaceBar>
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
<div bind:this={dynamicPadding}></div>
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
<div class="py-20">
<div class="card2 col-8 m-auto max-w-md items-center text-center">
@@ -405,7 +392,7 @@
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#each elements as { type, id, value, showPubkey, addSpaceBelow } (id)}
{#if type === "new-messages"}
<div
{id}
@@ -421,15 +408,14 @@
{@const event = $state.snapshot(value as TrustedEvent)}
{#if event.kind === ROOM_ADD_MEMBER}
<RoomItemAddMember {url} {event} />
{:else if event.kind === ROOM_REMOVE_MEMBER}
<RoomItemRemoveMember {url} {event} />
{:else}
<div in:slide class="cv" class:-mt-1={!showPubkey}>
<div in:slide class="cv">
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
{addSpaceBelow}
canEdit={canEditEvent}
onEdit={onEditEvent} />
</div>
@@ -444,11 +430,10 @@
{/if}
</p>
{/if}
<div class="h-screen"></div>
</PageContent>
<div
class="chat__compose-zone flex flex-col gap-1 bg-base-200 md:flex-row md:gap-0"
bind:this={chatCompose}>
<div class="chat__compose flex flex-col gap-1 bg-base-200 md:flex-row md:gap-0">
<div class="chat__compose-inner min-w-0 flex-1">
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
<!-- pass -->
@@ -490,7 +475,7 @@
{onSubmit}
{onEscape}
{onEditPrevious}
content={eventToEdit?.content}
initialValues={eventToEdit}
bind:this={compose} />
{/key}
{/if}
+26 -34
View File
@@ -6,7 +6,7 @@
import {readable} from "svelte/store"
import {now, int, ifLet, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER} from "@welshman/util"
import {makeEvent, MESSAGE, RELAY_ADD_MEMBER} from "@welshman/util"
import {pubkey, publishThunk} from "@welshman/app"
import {fade, fly} from "@lib/transition"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
@@ -21,7 +21,7 @@
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import RoomItem from "@app/components/RoomItem.svelte"
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
import RoomCompose from "@app/components/RoomCompose.svelte"
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
@@ -163,8 +163,6 @@
let share = $state(popKey<TrustedEvent | undefined>("share"))
let parent: TrustedEvent | undefined = $state()
let element: HTMLElement | undefined = $state()
let chatCompose: HTMLElement | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state()
let newMessagesSeen = false
let showFixedNewMessages = $state(false)
let showScrollButton = $state(false)
@@ -212,14 +210,21 @@
elements.push({type: "date", value: date, id: date, showPubkey: false})
}
const showPubkey =
previousPubkey !== event.pubkey ||
event.created_at - previousCreatedAt > int(3, MINUTE) ||
previousKind === RELAY_ADD_MEMBER
if (showPubkey && elements.length > 0) {
elements[elements.length - 1].addSpaceBelow = true
}
elements.push({
id: event.id,
type: "note",
value: event,
showPubkey:
previousPubkey !== event.pubkey ||
event.created_at - previousCreatedAt > int(3, MINUTE) ||
[RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER].includes(previousKind!),
showPubkey,
addSpaceBelow: false,
})
previousDate = date
@@ -228,6 +233,9 @@
previousCreatedAt = event.created_at
seen.add(event.id)
}
if (elements.length > 0) {
elements[elements.length - 1].addSpaceBelow = true
}
}
elements.reverse()
@@ -244,7 +252,7 @@
url,
at: at || now(),
element: element!,
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}],
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER]}],
onBackwardExhausted: () => {
loadingBackward = false
},
@@ -275,24 +283,9 @@
const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent)
onMount(() => {
const controller = new AbortController()
const observer = new ResizeObserver(() => {
if (dynamicPadding && chatCompose) {
dynamicPadding!.style.minHeight = `${chatCompose!.offsetHeight}px`
}
})
observer.observe(chatCompose!)
observer.observe(dynamicPadding!)
start()
return () => {
cleanup()
controller.abort()
observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
}
return cleanup
})
</script>
@@ -306,14 +299,13 @@
{/snippet}
</SpaceBar>
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
<div bind:this={dynamicPadding}></div>
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4 mb-14 md:mb-0">
{#if loadingForward}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#each elements as { type, id, value, showPubkey, addSpaceBelow } (id)}
{#if type === "new-messages"}
<div
{id}
@@ -329,17 +321,16 @@
{@const event = $state.snapshot(value as TrustedEvent)}
{#if event.kind === RELAY_ADD_MEMBER}
<RoomItemAddMember {url} {event} />
{:else if event.kind === RELAY_REMOVE_MEMBER}
<RoomItemRemoveMember {url} {event} />
{:else}
<div class:-mt-1={!showPubkey}>
<div>
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
onEdit={onEditEvent}
{addSpaceBelow} />
</div>
{/if}
{/if}
@@ -351,9 +342,10 @@
<Spinner>End of message history</Spinner>
{/if}
</p>
<div class="h-screen"></div>
</PageContent>
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
<div class="chat__compose bg-base-200">
<div>
{#if parent}
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
@@ -371,7 +363,7 @@
{onSubmit}
{onEscape}
{onEditPrevious}
content={eventToEdit?.content}
initialValues={eventToEdit}
bind:this={compose} />
{/key}
</div>
@@ -0,0 +1,95 @@
<script lang="ts">
import {onMount} from "svelte"
import {readable} from "svelte/store"
import type {Readable} from "svelte/store"
import {page} from "$app/stores"
import {sortBy, partition, spec, pushToMapKey, max} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import {fly} from "@lib/transition"
import PollIcon from "@assets/icons/revote.svg?dataurl"
import Add from "@assets/icons/add.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import PollItem from "@app/components/PollItem.svelte"
import PollCreate from "@app/components/PollCreate.svelte"
import {Poll} from "nostr-tools/kinds"
import {decodeRelay, makeCommentFilter} from "@app/core/state"
import {makeFeed} from "@app/core/requests"
import {pushModal} from "@app/util/modal"
const url = decodeRelay($page.params.relay!)
let loading = $state(true)
let element: HTMLElement | undefined = $state()
let events: Readable<TrustedEvent[]> = $state(readable([]))
const createPoll = () => pushModal(PollCreate, {url})
const items = $derived.by(() => {
const scores = new Map<string, number[]>()
const [polls, comments] = partition(spec({kind: Poll}), $events)
for (const comment of comments) {
const id = getTagValue("E", comment.tags)
if (id) {
pushToMapKey(scores, id, comment.created_at)
}
}
return sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), polls)
})
onMount(() => {
const feed = makeFeed({
url,
element: element!,
filters: [{kinds: [Poll]}, makeCommentFilter([Poll])],
onBackwardExhausted: () => {
loading = false
},
})
events = feed.events
return () => {
feed.cleanup()
}
})
</script>
<SpaceBar>
{#snippet title()}
<Icon icon={PollIcon} />
<strong>Polls</strong>
{/snippet}
{#snippet action()}
<Button class="btn btn-primary btn-sm" onclick={createPoll}>
<Icon icon={Add} />
Create
</Button>
{/snippet}
</SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 p-2 pt-4">
{#each items as event (event.id)}
<div in:fly>
<PollItem {url} event={$state.snapshot(event)} />
</div>
{/each}
<p class="flex h-10 items-center justify-center py-20">
<Spinner {loading}>
{#if loading}
Looking for polls...
{:else if items.length === 0}
No polls found.
{:else}
That's all!
{/if}
</Spinner>
</p>
</PageContent>
@@ -0,0 +1,107 @@
<script lang="ts">
import {onMount} from "svelte"
import {page} from "$app/stores"
import {sleep} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import {COMMENT} from "@welshman/util"
import {repository} from "@welshman/app"
import {request} from "@welshman/net"
import {deriveEventsById, deriveEventsAsc} from "@welshman/store"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import EventReply from "@app/components/EventReply.svelte"
import {deriveEvent, decodeRelay} from "@app/core/state"
import {Poll, PollResponse} from "nostr-tools/kinds"
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
const url = decodeRelay(relay)
const event = deriveEvent(id, [url])
const filters = [{kinds: [COMMENT], "#E": [id]}]
const comments = deriveEventsAsc(deriveEventsById({repository, filters}))
const back = () => history.back()
const openReply = () => {
showReply = true
}
const closeReply = () => {
showReply = false
}
const expand = () => {
showAll = true
}
let showAll = $state(false)
let showReply = $state(false)
onMount(() => {
const controller = new AbortController()
request({
relays: [url],
filters: [{kinds: [Poll], ids: [id]}, {kinds: [PollResponse], "#e": [id]}, ...filters],
signal: controller.signal,
})
return () => {
controller.abort()
}
})
</script>
<SpaceBar {back}>
{#snippet title()}
<h1 class="text-xl">{$event?.content || "Poll"}</h1>
{/snippet}
</SpaceBar>
<PageContent class="flex flex-col gap-3 p-2 pt-4">
{#if $event}
<div class="flex flex-col gap-3">
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
<div class="col-3 ml-12 flex flex-col gap-3">
<NoteContent showEntire event={$event} {url} />
<CommentActions segment="polls" showActivity {url} event={$event} />
</div>
</NoteCard>
{#if !showAll && $comments.length > 4}
<div class="flex justify-center">
<Button class="btn btn-link" onclick={expand}>
<Icon icon={SortVertical} />
Show all {$comments.length} comments
</Button>
</div>
{/if}
{#each $comments.slice(0, showAll ? undefined : 4) as reply (reply.id)}
<NoteCard event={reply} {url} class="card2 bg-alt z-feature w-full">
<div class="col-3 ml-12">
<NoteContent showEntire event={reply} {url} />
<CommentActions segment="polls" event={reply} {url} />
</div>
</NoteCard>
{/each}
</div>
{#if showReply}
<EventReply {url} event={$event} onClose={closeReply} onSubmit={closeReply} />
{:else}
<div class="flex justify-end p-2">
<Button class="btn btn-primary" onclick={openReply}>Comment on this poll</Button>
</div>
{/if}
{:else}
{#await sleep(5000)}
<Spinner loading>Loading poll...</Spinner>
{:then}
<p>Failed to load poll.</p>
{/await}
{/if}
</PageContent>
+205 -25
View File
@@ -1,8 +1,23 @@
<script lang="ts">
import {onMount} from "svelte"
import {tick, onMount} from "svelte"
import {derived} from "svelte/store"
import {page} from "$app/stores"
import {groupBy, ago, MONTH, first, sortBy, uniqBy} from "@welshman/lib"
import {debounce} from "throttle-debounce"
import {
formatTimestampAsDate,
groupBy,
ago,
now,
MONTH,
MINUTE,
HOUR,
DAY,
WEEK,
first,
sortBy,
uniqBy,
} from "@welshman/lib"
import {request} from "@welshman/net"
import {
MESSAGE,
THREAD,
@@ -13,12 +28,17 @@
getTagValue,
getTagValues,
getIdAndAddress,
sortEventsDesc,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {repository} from "@welshman/app"
import History from "@assets/icons/history.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import {createScroller} from "@lib/html"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import NoteItem from "@app/components/NoteItem.svelte"
@@ -26,8 +46,11 @@
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
import GoalItem from "@app/components/GoalItem.svelte"
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
import PollItem from "@app/components/PollItem.svelte"
import RecentConversation from "@app/components/RecentConversation.svelte"
import {decodeRelay, deriveEventsForUrl, CONTENT_KINDS} from "@app/core/state"
import {goToEvent} from "@app/util/routes"
import {Poll} from "nostr-tools/kinds"
const url = decodeRelay($page.params.relay!)
const since = ago(3, MONTH)
@@ -88,9 +111,93 @@
},
)
let term = $state("")
let showSearch = $state(false)
let loading = $state(false)
let searchResults: TrustedEvent[] = $state([])
let searchInput: HTMLInputElement | undefined = $state()
let controller: AbortController | undefined
let limit = $state(20)
let element: Element | undefined = $state()
const resultsByAge = $derived(groupBy(e => getAgeSection(e.created_at), searchResults))
const getAgeSection = (createdAt: number) => {
const age = now() - createdAt
if (age <= DAY) return "day"
if (age <= WEEK) return "week"
return "older"
}
const getAgeLabel = (createdAt: number) => {
const age = now() - createdAt
if (age < MINUTE) return "Just now"
if (age < HOUR) return `${Math.floor(age / MINUTE)}m ago`
if (age < DAY) return `${Math.floor(age / HOUR)}h ago`
return `${Math.floor(age / DAY)}d ago`
}
const openSearch = () => {
showSearch = true
tick().then(() => searchInput?.focus())
}
const closeSearch = () => {
showSearch = false
}
const clearSearch = () => {
term = ""
showSearch = false
loading = false
searchResults = []
controller?.abort()
controller = undefined
}
const search = debounce(300, async (searchTerm: string) => {
controller?.abort()
if (!searchTerm.trim()) {
loading = false
searchResults = []
return
}
controller = new AbortController()
loading = true
try {
const events = await request({
relays: [url],
autoClose: true,
signal: controller.signal,
filters: [{kinds: [MESSAGE, ...CONTENT_KINDS], search: searchTerm.trim()}],
})
searchResults = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, events))
} catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) {
searchResults = []
}
} finally {
loading = false
}
})
const onInput = () => {
showSearch = true
void search(term)
}
const onResultClick = (event: TrustedEvent) => {
closeSearch()
goToEvent(event, {keepFocus: true})
}
onMount(() => {
const scroller = createScroller({
element: element!,
@@ -108,28 +215,101 @@
<Icon icon={History} />
<strong>Recent Activity</strong>
{/snippet}
{#snippet action()}
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={openSearch}>
<Icon size={4} icon={Magnifier} />
</button>
{#if showSearch}
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={closeSearch}
></button>
<div class="fixed top-sai right-sai left-content z-feature p-2">
<div
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
transition:fly={{y: -40, duration: 150}}>
<div class="flex justify-between">
<strong>Search</strong>
<Button onclick={clearSearch}>
<Icon icon={CloseCircle} />
</Button>
</div>
<label class="input input-sm input-bordered flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input
bind:this={searchInput}
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder="Search this space..."
oninput={onInput} />
</label>
<div class="max-h-[65vh] overflow-y-auto">
{#if !term}
<p class="text-sm opacity-70">Search for content across this space.</p>
{:else if loading}
<p class="text-sm opacity-70">Searching...</p>
{:else if resultsByAge.size === 0}
<p class="text-sm opacity-70">No results found.</p>
{:else}
<div class="col-2">
{#each resultsByAge as [key, events] (key)}
<div class="col-2">
<p class="text-xs uppercase tracking-wide opacity-60">
{#if key === "day"}
Last 24 Hours
{:else if key === "week"}
Last 7 Days
{:else}
Older
{/if}
</p>
<div class="col-2">
{#each events as event (event.id)}
<button
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
onclick={() => onResultClick(event)}>
<p class="line-clamp-2 text-sm">
{event.content.trim() ||
getTagValue("title", event.tags) ||
"(No text content)"}
</p>
<div class="row-2 text-xs opacity-70">
<span>{getAgeLabel(event.created_at)}</span>
<span>{formatTimestampAsDate(event.created_at)}</span>
</div>
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
{/snippet}
</SpaceBar>
<div bind:this={element}>
<PageContent class="flex flex-col gap-2 p-2 pt-4">
{#if $recentActivity.length === 0}
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
{:else}
{#each $recentActivity.slice(0, limit) as { type, event, count = 0 } (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}
{/each}
{/if}
</PageContent>
</div>
<PageContent class="flex flex-col gap-2 p-2 pt-4" bind:element>
{#if $recentActivity.length === 0}
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
{:else}
{#each $recentActivity.slice(0, limit) as { type, event, count = 0 } (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 if event.kind === Poll}
<PollItem {url} {event} />
{:else}
<NoteItem {url} {event} />
{/if}
{/each}
{/if}
</PageContent>
+2 -2
View File
@@ -8,8 +8,8 @@
import PageContent from "@lib/components/PageContent.svelte"
</script>
<Page class="cw-full">
<PageContent class="cw-full flex flex-col items-center gap-2 p-2 pt-4">
<Page>
<PageContent class="flex flex-col items-center gap-2 p-2 pt-4">
<PageHeader>
{#snippet title()}
<div>Create your own Space</div>
+3 -3
View File
@@ -3,13 +3,13 @@ import daisyui from "daisyui"
import themes from "daisyui/src/theming/themes"
config({path: ".env.local"})
config({path: ".env.template"})
config({path: ".env"})
/** @type {import('tailwindcss').Config} */
export default {
content: ["./src/**/*.{html,js,svelte,ts}"],
darkMode: ["selector", '[data-theme="dark"]'],
safelist: ["bg-success", "bg-warning", 'w-4 h-4'],
safelist: ["bg-success", "bg-warning", "w-4 h-4"],
theme: {
extend: {},
zIndex: {
@@ -21,7 +21,7 @@ export default {
nav: 5,
popover: 6,
modal: 7,
"modal-feature": 8,
tooltip: 8,
toast: 9,
},
},
+1 -1
View File
@@ -5,7 +5,7 @@ import {sveltekit} from "@sveltejs/kit/vite"
import svg from "@poppanator/sveltekit-svg"
config({path: ".env.local"})
config({path: ".env.template"})
config({path: ".env"})
export default defineConfig({
server: {