forked from coracle/flotilla
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e41680fff |
Binary file not shown.
|
After Width: | Height: | Size: 340 KiB |
+203
-12
@@ -2,10 +2,15 @@
|
||||
|
||||
@config "../tailwind.config.js";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Lato", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: "Baloo 2", "Lato", ui-rounded, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* root */
|
||||
|
||||
:root {
|
||||
font-family: Lato;
|
||||
font-family: var(--font-sans);
|
||||
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
|
||||
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
||||
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
|
||||
@@ -153,15 +158,15 @@
|
||||
}
|
||||
|
||||
@utility heading {
|
||||
@apply text-center text-2xl;
|
||||
@apply font-display text-center text-2xl font-bold tracking-tight;
|
||||
}
|
||||
|
||||
@utility subheading {
|
||||
@apply text-center text-xl;
|
||||
@utility brand {
|
||||
@apply font-display text-primary font-bold tracking-tight;
|
||||
}
|
||||
|
||||
@utility superheading {
|
||||
@apply text-center text-4xl;
|
||||
@utility label {
|
||||
@apply font-display text-sm font-semibold tracking-wider uppercase opacity-70;
|
||||
}
|
||||
|
||||
@utility link {
|
||||
@@ -215,8 +220,19 @@
|
||||
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
font-style: bold;
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Lato-Light.ttf") format("truetype");
|
||||
}
|
||||
|
||||
/* Lato ships Regular + Bold only; map 600 (semibold) and 700 (bold) to the
|
||||
Bold file so the browser never synthesizes a faux-bold. */
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
font-style: normal;
|
||||
font-weight: 600 700;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Lato-Bold.ttf") format("truetype");
|
||||
@@ -228,13 +244,38 @@
|
||||
font-weight: 400;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Italic.ttf") format("truetype");
|
||||
url("/fonts/Lato-Italic.ttf") format("truetype");
|
||||
}
|
||||
|
||||
/* Baloo 2 — rounded, friendly display face (self-hosted, Latin subset). */
|
||||
@font-face {
|
||||
font-family: "Baloo 2";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/fonts/Baloo2-Medium.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Baloo 2";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("/fonts/Baloo2-SemiBold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Baloo 2";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/fonts/Baloo2-Bold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
/* root */
|
||||
|
||||
:root {
|
||||
font-family: Lato;
|
||||
font-family: var(--font-sans);
|
||||
text-size-adjust: 100%;
|
||||
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
|
||||
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
||||
@@ -284,7 +325,7 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* tiptap */
|
||||
/* editors */
|
||||
|
||||
.input-editor,
|
||||
.chat-editor,
|
||||
@@ -323,7 +364,11 @@
|
||||
}
|
||||
|
||||
.chat-editor .tiptap {
|
||||
@apply rounded-box bg-base-300 pr-12;
|
||||
@apply bg-base-300 rounded-[1.5rem] pr-12 transition-shadow;
|
||||
}
|
||||
|
||||
.chat-editor:focus-within .tiptap {
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary), transparent 55%);
|
||||
}
|
||||
|
||||
.note-editor .tiptap {
|
||||
@@ -448,3 +493,149 @@ body.keyboard-open .chat__compose {
|
||||
.chat__scroll-down {
|
||||
@apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16;
|
||||
}
|
||||
|
||||
/* shape, depth & motion */
|
||||
|
||||
/* Accessibility: neutralize all motion when the user asks for it. Decorative
|
||||
motion is otherwise opt-in via `motion-safe:` and the guards below. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Soft, diffuse elevation — replaces ad-hoc hard `shadow-md` uses. */
|
||||
@utility shadow-soft {
|
||||
box-shadow:
|
||||
0 4px 16px -4px oklch(0% 0 0 / 0.18),
|
||||
0 1px 3px oklch(0% 0 0 / 0.08);
|
||||
}
|
||||
|
||||
/* Organic "hand-drawn" avatar masks. The image (or gradient fallback) fills
|
||||
the blob; three variants are chosen deterministically by pubkey hash so a
|
||||
person's shape stays stable across the app. */
|
||||
@utility avatar-blob {
|
||||
border-radius: 42% 58% 54% 46% / 58% 46% 54% 42%;
|
||||
}
|
||||
@utility avatar-blob-2 {
|
||||
border-radius: 60% 40% 46% 54% / 43% 57% 43% 57%;
|
||||
}
|
||||
@utility avatar-blob-3 {
|
||||
border-radius: 47% 53% 62% 38% / 50% 62% 38% 50%;
|
||||
}
|
||||
|
||||
/* Friendly rounded-square for space / relay / room tiles. */
|
||||
@utility squircle {
|
||||
border-radius: 30%;
|
||||
}
|
||||
|
||||
/* Every DaisyUI button speaks in the rounded display voice and presses in. */
|
||||
.btn {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.btn {
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
background-color 150ms ease,
|
||||
border-color 150ms ease;
|
||||
}
|
||||
.btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Motion vocabulary ---- */
|
||||
@keyframes nav-button-pop {
|
||||
0% {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes button-pop {
|
||||
0% {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes pop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes reaction-pop {
|
||||
0% {
|
||||
transform: scale(0.6);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
@keyframes wiggle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-4deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(4deg);
|
||||
}
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-pop {
|
||||
animation: pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
@utility animate-reaction-pop {
|
||||
animation: reaction-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
@utility animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
@utility animate-wiggle {
|
||||
animation: wiggle 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
</div>
|
||||
<Button
|
||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
||||
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
|
||||
class="center tooltip tooltip-left bg-primary text-primary-content absolute top-[7px] right-[7px] h-11 w-11 min-w-11 scale-90 rounded-full transition-transform motion-safe:hover:scale-100"
|
||||
disabled={$uploading || disabled}
|
||||
onclick={submit}>
|
||||
<Icon icon={Plane} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {type Instance} from "tippy.js"
|
||||
import {hash, formatTimestampAsTime} from "@welshman/lib"
|
||||
import {formatTimestampAsTime} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {thunks, mergeThunks, pubkey, deriveProfileDisplay, sendWrapped} from "@welshman/app"
|
||||
import {isMobile} from "@lib/html"
|
||||
@@ -16,7 +16,7 @@
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
|
||||
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
|
||||
import {colors} from "@app/theme"
|
||||
import {getColor} from "@app/theme"
|
||||
import {makeDelete} from "@app/deletes"
|
||||
import {makeReaction} from "@app/reactions"
|
||||
import {pushModal} from "@app/modal"
|
||||
@@ -35,7 +35,7 @@
|
||||
const isOwn = event.pubkey === $pubkey
|
||||
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
||||
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
|
||||
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
|
||||
const colorValue = getColor(event.pubkey)
|
||||
|
||||
const reply = () => replyTo(event)
|
||||
const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined
|
||||
@@ -101,7 +101,9 @@
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-col" class:items-end={isOwn}>
|
||||
<TapTarget
|
||||
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl min-w-[100px]"
|
||||
class="chat-bubble shadow-soft mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl min-w-[100px] {isOwn
|
||||
? 'bg-primary text-primary-content'
|
||||
: 'bg-base-100'}"
|
||||
onTap={showMobileMenu}>
|
||||
{#if showPubkey}
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -109,7 +111,7 @@
|
||||
<Button onclick={openProfile} class="flex items-center gap-1">
|
||||
<ProfileCircle
|
||||
pubkey={event.pubkey}
|
||||
class="border border-solid border-base-content"
|
||||
style="box-shadow: 0 0 0 1.5px {colorValue}"
|
||||
size={4} />
|
||||
<div class="flex items-center gap-2">
|
||||
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import NoteCard from "@app/components/NoteCard.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||
import {deriveEvent} from "@app/repository"
|
||||
import {entityLink} from "@app/env"
|
||||
@@ -44,9 +43,7 @@
|
||||
|
||||
<Button class="my-2 block w-full max-w-full text-left" {onclick}>
|
||||
{#if $quote}
|
||||
{#if $quote.content.trim().match(/^(nostr:)?nevent1[a-z0-9]+$/)}
|
||||
<NoteContent {url} event={$quote} />
|
||||
{:else if $quote.kind === MESSAGE}
|
||||
{#if $quote.kind === MESSAGE}
|
||||
<div
|
||||
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
|
||||
style="background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-base-300) 90%);">
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
||||
type Props = {
|
||||
icon?: string
|
||||
title: string
|
||||
children?: Snippet
|
||||
action?: Snippet
|
||||
}
|
||||
|
||||
const {icon, title, children, action}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="m-auto flex max-w-sm flex-col items-center gap-3 px-4 py-12 text-center"
|
||||
in:fly={{y: 16}}>
|
||||
{#if icon}
|
||||
<div class="bg-primary/10 text-primary center size-16 rounded-full motion-safe:animate-float">
|
||||
<Icon {icon} size={8} />
|
||||
</div>
|
||||
{/if}
|
||||
<h3 class="font-display text-xl font-bold tracking-tight">{title}</h3>
|
||||
{#if children}
|
||||
<p class="text-sm opacity-70">{@render children?.()}</p>
|
||||
{/if}
|
||||
{#if action}
|
||||
<div class="mt-1">{@render action?.()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,17 +1,15 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import {fly} from "@lib/transition"
|
||||
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
||||
import {publishComment} from "@app/comments"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {PROTECTED, prependParent} from "@app/groups"
|
||||
import {PROTECTED} from "@app/groups"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {DraftKey} from "@app/drafts"
|
||||
import {pushToast} from "@app/toast"
|
||||
@@ -20,17 +18,8 @@
|
||||
content?: string | object
|
||||
}
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
parent?: TrustedEvent
|
||||
onClose: () => void
|
||||
onClearParent?: () => void
|
||||
onSubmit: (thunk: unknown) => void
|
||||
}
|
||||
|
||||
const {url, event, parent, onClose, onClearParent, onSubmit}: Props = $props()
|
||||
const draftKey = new DraftKey<Values>(`reply:${event.id}:${parent?.id || ""}`)
|
||||
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)
|
||||
@@ -42,8 +31,8 @@
|
||||
if ($uploading) return
|
||||
|
||||
const ed = await editor
|
||||
let content = ed.getText({blockSeparator: "\n"}).trim()
|
||||
let tags = ed.storage.nostr.getEditorTags()
|
||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||
const tags = ed.storage.nostr.getEditorTags()
|
||||
|
||||
if (await shouldProtect) {
|
||||
tags.push(PROTECTED)
|
||||
@@ -56,10 +45,6 @@
|
||||
})
|
||||
}
|
||||
|
||||
if (parent) {
|
||||
;({content, tags} = prependParent(parent, {content, tags}, url))
|
||||
}
|
||||
|
||||
draftKey.clear()
|
||||
onSubmit(publishComment({event, content, tags, relays: [url]}))
|
||||
}
|
||||
@@ -102,9 +87,6 @@
|
||||
onsubmit={preventDefault(submit)}
|
||||
class="left-content bottom-sai right-sai fixed z-feature mb-14 md:mb-0 w-full md:w-auto pr-2">
|
||||
<div class="card2 mx-2 my-2 bg-alt shadow-md">
|
||||
{#if parent}
|
||||
<ChatComposeParent event={parent} clear={() => onClearParent?.()} verb="Replying to" />
|
||||
{/if}
|
||||
<div class="relative">
|
||||
<div class="note-editor grow overflow-hidden">
|
||||
<EditorContent {autofocus} {editor} />
|
||||
|
||||
@@ -80,8 +80,9 @@
|
||||
for={id}
|
||||
aria-label="Drag and drop files here."
|
||||
style="background-image: url({url});"
|
||||
class="relative flex h-24 w-24 shrink-0 cursor-pointer items-center justify-center rounded-full border-2 border-solid border-base-content bg-base-300 bg-cover bg-center transition-all"
|
||||
class="avatar-blob relative flex h-24 w-24 shrink-0 cursor-pointer items-center justify-center border-2 border-dashed border-primary/40 bg-base-300 bg-cover bg-center transition-all motion-safe:hover:rotate-1 motion-safe:hover:scale-[1.02]"
|
||||
class:transparent={!url}
|
||||
class:border-solid={url || active}
|
||||
class:border-primary={active}
|
||||
ondragenter={stopPropagation(preventDefault(onDragEnter))}
|
||||
ondragover={stopPropagation(preventDefault(onDragOver))}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import CardButton from "@lib/components/CardButton.svelte"
|
||||
import LogIn from "@app/components/LogIn.svelte"
|
||||
import SignUp from "@app/components/SignUp.svelte"
|
||||
import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME} from "@app/env"
|
||||
import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME, PLATFORM_LOGO} from "@app/env"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
const logIn = () => pushModal(LogIn)
|
||||
@@ -19,9 +19,15 @@
|
||||
|
||||
<Modal>
|
||||
<ModalBody>
|
||||
<div class="py-2">
|
||||
<h1 class="heading">Welcome to {PLATFORM_NAME}!</h1>
|
||||
<p class="text-center">The chat app built for self-hosted communities.</p>
|
||||
<div class="flex flex-col items-center gap-3 py-2">
|
||||
<img
|
||||
src={PLATFORM_LOGO}
|
||||
alt={PLATFORM_NAME}
|
||||
class="shadow-soft ring-primary/20 size-16 rounded-2xl object-cover ring-4 motion-safe:animate-float" />
|
||||
<h1 class="heading">Welcome to <span class="brand">{PLATFORM_NAME}</span>!</h1>
|
||||
<p class="max-w-sm text-center opacity-80">
|
||||
A cozy home for your community — chat, connect, and own your little corner of the internet.
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={logIn}>
|
||||
<CardButton class="btn-primary">
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
onclick={() => selectAccount(option)}
|
||||
disabled={loading}
|
||||
class="card2 bg-alt flex w-full items-center p-3 text-left">
|
||||
<Profile inert pubkey={option.pubkey} />
|
||||
<Profile pubkey={option.pubkey} />
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -4,16 +4,11 @@
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {Router} from "@welshman/router"
|
||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import {fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Popover from "@lib/components/Popover.svelte"
|
||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import NoteCard from "@app/components/NoteCard.svelte"
|
||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import ProfileNoteMenu from "@app/components/ProfileNoteMenu.svelte"
|
||||
import {publishDelete} from "@app/deletes"
|
||||
import {publishReaction} from "@app/reactions"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
@@ -22,10 +17,9 @@
|
||||
event: TrustedEvent
|
||||
children?: Snippet
|
||||
url?: string
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
const {url, event, children, editable = false}: Props = $props()
|
||||
const {url, event, children}: Props = $props()
|
||||
|
||||
const relays = url ? [url] : Router.get().Event(event).getUrls()
|
||||
|
||||
@@ -44,35 +38,9 @@
|
||||
content: emoji.unicode,
|
||||
protect: await shouldProtect,
|
||||
})
|
||||
|
||||
const toggleMenu = () => {
|
||||
showMenu = !showMenu
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
showMenu = false
|
||||
}
|
||||
|
||||
let showMenu = $state(false)
|
||||
</script>
|
||||
|
||||
<NoteCard {event} {url} class="cv card2 bg-alt">
|
||||
{#if editable}
|
||||
<div class="flex justify-end">
|
||||
<div class="relative">
|
||||
<Button class="btn btn-circle btn-ghost btn-xs" onclick={toggleMenu}>
|
||||
<Icon icon={MenuDots} size={4} />
|
||||
</Button>
|
||||
{#if showMenu}
|
||||
<Popover hideOnClick onClose={closeMenu}>
|
||||
<div transition:fly class="absolute right-0 z-popover">
|
||||
<ProfileNoteMenu {event} onClose={closeMenu} />
|
||||
</div>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<NoteContent {event} expandMode="inline" />
|
||||
<div class="flex w-full justify-between gap-2">
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right">
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Profile from "@app/components/Profile.svelte"
|
||||
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
||||
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
||||
import {makeProfilePath} from "@app/routes"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
@@ -13,20 +14,22 @@
|
||||
}
|
||||
|
||||
const {pubkey, url}: Props = $props()
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
|
||||
</script>
|
||||
|
||||
<div class="card2 bg-alt flex flex-col gap-4 shadow-md">
|
||||
<div class="flex justify-between">
|
||||
<Profile {pubkey} {url} />
|
||||
<Link href={makeProfilePath(pubkey)} class="btn btn-primary hidden sm:flex">
|
||||
<Button onclick={openProfile} class="btn btn-primary hidden sm:flex">
|
||||
<Icon icon={UserCircle} />
|
||||
View Profile
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<ProfileInfo {pubkey} {url} />
|
||||
<ProfileBadges {pubkey} {url} />
|
||||
<Link href={makeProfilePath(pubkey)} class="btn btn-primary sm:hidden">
|
||||
<Button onclick={openProfile} class="btn btn-primary sm:hidden">
|
||||
<Icon icon={UserCircle} />
|
||||
View Profile
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -29,8 +29,10 @@
|
||||
const openSpaces = () => pushModal(ProfileSpaces, {pubkey, url})
|
||||
|
||||
onMount(async () => {
|
||||
// Make sure we have their relay selections before we load their posts
|
||||
await loadRelayList(pubkey)
|
||||
|
||||
// Load groups and at least one note, regardless of time frame
|
||||
load({
|
||||
filters: [
|
||||
{authors: [pubkey], kinds: [ROOMS]},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {removeUndefined} from "@welshman/lib"
|
||||
import {deriveProfile} from "@welshman/app"
|
||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||
import {deriveProfile, deriveProfileDisplay} from "@welshman/app"
|
||||
import {getColor, getBlobVariant} from "@app/theme"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
|
||||
type Props = {
|
||||
@@ -10,15 +10,39 @@
|
||||
class?: string
|
||||
size?: number
|
||||
url?: string
|
||||
shape?: "blob" | "circle"
|
||||
style?: string
|
||||
}
|
||||
|
||||
const {pubkey, url, size = 7, ...props}: Props = $props()
|
||||
const {pubkey, url, size = 7, shape = "blob", style = "", ...props}: Props = $props()
|
||||
|
||||
const profile = deriveProfile(pubkey, removeUndefined([url]))
|
||||
const display = deriveProfileDisplay(pubkey)
|
||||
|
||||
// Organic, hand-drawn-feeling mask. The variant is stable per pubkey so a
|
||||
// person's silhouette never changes; `shape="circle"` opts back into a disc.
|
||||
const shapeClass =
|
||||
shape === "circle"
|
||||
? "rounded-full"
|
||||
: ["avatar-blob", "avatar-blob-2", "avatar-blob-3"][getBlobVariant(pubkey) - 1]
|
||||
|
||||
const color = getColor(pubkey)
|
||||
const px = $derived(size * 4)
|
||||
const initial = $derived([...($display || "")].find(c => c.trim()) || "?")
|
||||
</script>
|
||||
|
||||
<ImageIcon
|
||||
{size}
|
||||
alt=""
|
||||
class={cx(props.class, "rounded-full")}
|
||||
src={$profile?.picture || UserRounded} />
|
||||
{#if $profile?.picture}
|
||||
<ImageIcon {size} alt="" {style} class={cx(props.class, shapeClass)} src={$profile.picture} />
|
||||
{:else}
|
||||
<!-- Fallback: a subtle gradient derived from the pubkey + the person's initial. -->
|
||||
<div
|
||||
class={cx(
|
||||
props.class,
|
||||
shapeClass,
|
||||
"font-display flex shrink-0 items-center justify-center font-bold text-white uppercase select-none",
|
||||
)}
|
||||
style="width:{px}px;height:{px}px;font-size:{px *
|
||||
0.45}px;background-image:linear-gradient(135deg,{color},color-mix(in oklab,{color},#000 28%));{style}">
|
||||
{initial}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {loadProfile} from "@welshman/app"
|
||||
import {getProfile, loadProfile} from "@welshman/app"
|
||||
import {isMobile} from "@lib/html"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
|
||||
@@ -14,39 +14,46 @@
|
||||
const {pubkeys, size = 7, limit, class: className}: Props = $props()
|
||||
const effectiveLimit = $derived(limit ?? (isMobile ? 7 : 10))
|
||||
|
||||
// circle is one step smaller than box so the bg-base-100 wrapper reads as a
|
||||
// thin separating ring between overlapping avatars (Discord-style stack).
|
||||
const dimensions = $derived(
|
||||
size <= 5
|
||||
? {box: "h-5 w-5", overlap: "-mr-2", overflow: "text-[9px]"}
|
||||
? {box: "h-5 w-5", circle: 4, overlap: "-mr-2", overflow: "text-[9px]"}
|
||||
: size <= 6
|
||||
? {box: "h-6 w-6", overlap: "-mr-2.5", overflow: "text-[10px]"}
|
||||
: {box: "h-8 w-8", overlap: "-mr-3", overflow: "text-xs"},
|
||||
? {box: "h-6 w-6", circle: 5, overlap: "-mr-2.5", overflow: "text-[10px]"}
|
||||
: {box: "h-8 w-8", circle: 7, overlap: "-mr-3", overflow: "text-xs"},
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
for (const pk of pubkeys) {
|
||||
loadProfile(pk)
|
||||
}
|
||||
for (const pubkey of pubkeys) {
|
||||
loadProfile(pubkey)
|
||||
}
|
||||
|
||||
const visiblePubkeys = $derived.by(() => {
|
||||
const filtered = pubkeys.filter(pubkey => getProfile(pubkey)?.picture)
|
||||
|
||||
return filtered.length > 0 ? filtered : pubkeys.slice(0, 1)
|
||||
})
|
||||
|
||||
const displayPubkeys = $derived([...pubkeys].toSorted().slice(0, effectiveLimit))
|
||||
const displayPubkeys = $derived(visiblePubkeys.toSorted().slice(0, effectiveLimit))
|
||||
const overflowCount = $derived(Math.max(0, pubkeys.length - effectiveLimit))
|
||||
</script>
|
||||
|
||||
<div class={cx("flex", size <= 5 ? "pr-2" : "pr-3", className)}>
|
||||
{#each displayPubkeys as pubkey (pubkey)}
|
||||
{#each displayPubkeys as pubkey, i (pubkey)}
|
||||
<div
|
||||
class={cx(
|
||||
"z-feature inline-block flex items-center justify-center rounded-full bg-base-100",
|
||||
"z-feature inline-flex items-center justify-center rounded-full bg-base-100 transition-transform",
|
||||
dimensions.box,
|
||||
dimensions.overlap,
|
||||
i % 2 === 0 ? "rotate-2" : "-rotate-2",
|
||||
)}>
|
||||
<ProfileCircle class={cx(dimensions.box, "bg-base-300")} {pubkey} {size} />
|
||||
<ProfileCircle class="bg-base-300" shape="circle" {pubkey} size={dimensions.circle} />
|
||||
</div>
|
||||
{/each}
|
||||
{#if overflowCount > 0}
|
||||
<div
|
||||
class={cx(
|
||||
"z-feature inline-flex items-center justify-center rounded-full bg-neutral font-medium text-neutral-content",
|
||||
"z-feature bg-primary text-primary-content shadow-soft font-display inline-flex rotate-2 items-center justify-center rounded-full font-bold",
|
||||
dimensions.box,
|
||||
dimensions.overlap,
|
||||
dimensions.overflow,
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
} from "@welshman/app"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||
import Letter from "@assets/icons/letter-opened.svg?dataurl"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||
import Restart from "@assets/icons/restart.svg?dataurl"
|
||||
import {fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Popover from "@lib/components/Popover.svelte"
|
||||
@@ -26,10 +28,11 @@
|
||||
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
||||
import {pubkeyLink} from "@app/env"
|
||||
import {deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems, addSpaceMembers} from "@app/members"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {goToProfile} from "@app/routes"
|
||||
import {goToChat} from "@app/routes"
|
||||
|
||||
export type Props = {
|
||||
pubkey: string
|
||||
@@ -50,9 +53,9 @@
|
||||
|
||||
const showInfo = () => pushModal(EventInfo, {url, event: $profile!.event})
|
||||
|
||||
const viewProfile = () => goToProfile(pubkey)
|
||||
const openChat = () => goToChat([pubkey])
|
||||
|
||||
const toggleMenu = () => {
|
||||
const toggleMenu = (pubkey: string) => {
|
||||
showMenu = !showMenu
|
||||
}
|
||||
|
||||
@@ -104,7 +107,7 @@
|
||||
<Profile showPubkey avatarSize={14} {pubkey} {url} />
|
||||
{#if $profile || $userIsAdmin}
|
||||
<div class="relative">
|
||||
<Button class="btn btn-circle btn-ghost btn-sm" onclick={toggleMenu}>
|
||||
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
|
||||
<Icon icon={MenuDots} />
|
||||
</Button>
|
||||
{#if showMenu}
|
||||
@@ -153,9 +156,13 @@
|
||||
Go back
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button onclick={viewProfile} class="btn btn-primary">
|
||||
<Icon icon={UserCircle} />
|
||||
View Full Profile
|
||||
<Link external href={pubkeyLink(pubkey)} class="btn btn-neutral">
|
||||
<ImageIcon alt="" src="/coracle.png" />
|
||||
Open in Coracle
|
||||
</Link>
|
||||
<Button onclick={openChat} class="btn btn-primary">
|
||||
<Icon icon={Letter} />
|
||||
Message
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import {preventDefault} from "@lib/html"
|
||||
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||
import MapPoint from "@assets/icons/map-point.svg?dataurl"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
@@ -86,24 +85,6 @@
|
||||
{/snippet}
|
||||
</Field>
|
||||
{#if !isSignup}
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Website</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={LinkRound} />
|
||||
<input
|
||||
bind:value={values.profile.website}
|
||||
class="grow"
|
||||
type="text"
|
||||
placeholder="https://" />
|
||||
</label>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
A link to your personal site or portfolio.
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Nostr Address</p>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {nthEq} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getListTags, getEventTagValues} from "@welshman/util"
|
||||
import {pin, unpin, tagEvent, userPinList, waitForThunkError} from "@welshman/app"
|
||||
import {Router} from "@welshman/router"
|
||||
import Pin from "@assets/icons/pin.svg?dataurl"
|
||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import {publishDelete} from "@app/deletes"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
event: TrustedEvent
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const {event, onClose}: Props = $props()
|
||||
|
||||
const relays = Router.get().Event(event).getUrls()
|
||||
const pinnedIds = $derived(getEventTagValues(getListTags($userPinList)))
|
||||
const isPinned = $derived(pinnedIds.includes(event.id))
|
||||
|
||||
const togglePin = async () => {
|
||||
onClose()
|
||||
|
||||
const thunk = isPinned ? await unpin(event.id) : await pin(tagEvent(event).find(nthEq(0, "e"))!)
|
||||
|
||||
const error = await waitForThunkError(thunk)
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: "Failed to update pinned notes."})
|
||||
} else {
|
||||
pushToast({message: isPinned ? "Note unpinned." : "Note pinned to your profile."})
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
onClose()
|
||||
|
||||
pushModal(Confirm, {
|
||||
title: "Delete Note",
|
||||
message: "Are you sure you want to delete this note?",
|
||||
confirm: async () => {
|
||||
await publishDelete({event, relays, protect: false})
|
||||
|
||||
pushToast({message: "Delete request sent."})
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-md">
|
||||
<li>
|
||||
<Button onclick={togglePin}>
|
||||
<Icon size={4} icon={Pin} />
|
||||
{isPinned ? "Unpin from profile" : "Pin to profile"}
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={confirmDelete} class="text-error">
|
||||
<Icon size={4} icon={TrashBin2} />
|
||||
Delete note
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -1,382 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import cx from "classnames"
|
||||
import {goto} from "$app/navigation"
|
||||
import {compressFile} from "@lib/html"
|
||||
import {displayPubkey} from "@welshman/util"
|
||||
import {
|
||||
pubkey,
|
||||
followLists,
|
||||
deriveProfile,
|
||||
deriveProfileDisplay,
|
||||
deriveUserWotScore,
|
||||
getFollows,
|
||||
follow,
|
||||
unfollow,
|
||||
tagPubkey,
|
||||
} from "@welshman/app"
|
||||
import {clamp} from "@welshman/lib"
|
||||
import Copy from "@assets/icons/copy.svg?dataurl"
|
||||
import QrCode from "@assets/icons/qr-code.svg?dataurl"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import SquareArrowRight from "@assets/icons/square-arrow-right-up.svg?dataurl"
|
||||
import Letter from "@assets/icons/letter-opened.svg?dataurl"
|
||||
import UserPlus from "@assets/icons/user-plus.svg?dataurl"
|
||||
import PenNewSquare from "@assets/icons/pen-new-square.svg?dataurl"
|
||||
import Settings from "@assets/icons/settings.svg?dataurl"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||
import Shield from "@assets/icons/shield-minimalistic.svg?dataurl"
|
||||
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
|
||||
import {fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Popover from "@lib/components/Popover.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
||||
import ProfileTrust from "@app/components/ProfileTrust.svelte"
|
||||
import ProfileSharedSpaces from "@app/components/ProfileSharedSpaces.svelte"
|
||||
import ProfilePinnedNotes from "@app/components/ProfilePinnedNotes.svelte"
|
||||
import ProfilePageNotes from "@app/components/ProfilePageNotes.svelte"
|
||||
import ProfilePageSpaces from "@app/components/ProfilePageSpaces.svelte"
|
||||
import ProfileEdit from "@app/components/ProfileEdit.svelte"
|
||||
import ProfileQrCode from "@app/components/ProfileQrCode.svelte"
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import {deriveGroupList, getSpaceUrlsFromGroupList, userSpaceUrls} from "@app/groups"
|
||||
import {updateProfile} from "@app/profiles"
|
||||
import {uploadFile} from "@app/uploads"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {clip, pushToast} from "@app/toast"
|
||||
import {goToChat} from "@app/routes"
|
||||
|
||||
type Tab = "about" | "notes" | "spaces"
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
const {pubkey: target}: Props = $props()
|
||||
|
||||
const profile = deriveProfile(target)
|
||||
const profileDisplay = deriveProfileDisplay(target)
|
||||
const groupList = deriveGroupList(target)
|
||||
const score = deriveUserWotScore(target)
|
||||
const encodedNpub = nip19.npubEncode(target)
|
||||
const isSelf = $derived($pubkey === target)
|
||||
const isFollowing = $derived.by(() => {
|
||||
void $followLists
|
||||
|
||||
return $pubkey ? getFollows($pubkey).includes(target) : false
|
||||
})
|
||||
const spaceUrls = $derived(getSpaceUrlsFromGroupList($groupList))
|
||||
const sharedSpaceUrls = $derived($userSpaceUrls.filter(url => spaceUrls.includes(url)))
|
||||
const displayScore = $derived(Math.round(clamp([0, 100], $score)))
|
||||
const website = $derived($profile?.website?.replace(/^https?:\/\//, ""))
|
||||
const websiteHref = $derived(
|
||||
$profile?.website?.match(/^https?:\/\//)
|
||||
? $profile.website
|
||||
: `https://${$profile?.website || ""}`,
|
||||
)
|
||||
|
||||
let tab = $state<Tab>("about")
|
||||
let showMenu = $state(false)
|
||||
let bannerLoading = $state(false)
|
||||
let bannerInput: HTMLInputElement | undefined = $state()
|
||||
|
||||
const setTab = (next: Tab) => {
|
||||
tab = next
|
||||
}
|
||||
|
||||
const showAboutTab = () => setTab("about")
|
||||
|
||||
const showNotesTab = () => setTab("notes")
|
||||
|
||||
const showSpacesTab = () => setTab("spaces")
|
||||
|
||||
const copyNpub = () => clip(encodedNpub)
|
||||
|
||||
const showQr = () => pushModal(ProfileQrCode, {code: encodedNpub})
|
||||
|
||||
const toggleMenu = () => {
|
||||
showMenu = !showMenu
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
showMenu = false
|
||||
}
|
||||
|
||||
const showInfo = () => {
|
||||
closeMenu()
|
||||
pushModal(EventInfo, {event: $profile!.event})
|
||||
}
|
||||
|
||||
const startEdit = () => pushModal(ProfileEdit)
|
||||
|
||||
const openSettings = () => goto("/settings/profile")
|
||||
|
||||
const openSpaces = () => goto("/spaces")
|
||||
|
||||
const openRelaySettings = () => goto("/settings/relays")
|
||||
|
||||
const openChat = () => goToChat([target])
|
||||
|
||||
const toggleFollow = async () => {
|
||||
if (!$pubkey || isSelf) return
|
||||
|
||||
if (isFollowing) {
|
||||
await unfollow(target)
|
||||
} else {
|
||||
await follow(tagPubkey(target))
|
||||
}
|
||||
}
|
||||
|
||||
const openBannerPicker = () => bannerInput?.click()
|
||||
|
||||
const onBannerChange = async (e: Event) => {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
|
||||
input.value = ""
|
||||
|
||||
if (!file || !$profile) return
|
||||
|
||||
bannerLoading = true
|
||||
|
||||
try {
|
||||
const {result} = await uploadFile(await compressFile(file))
|
||||
|
||||
if (result?.url) {
|
||||
await updateProfile({profile: {...$profile, banner: result.url}})
|
||||
pushToast({message: "Banner updated."})
|
||||
}
|
||||
} finally {
|
||||
bannerLoading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="col-4">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-start">
|
||||
<div class="min-w-0 flex-1 overflow-hidden border-base-300 bg-alt md:rounded-box md:border">
|
||||
<div class="relative overflow-hidden border-b border-base-300 bg-base-300">
|
||||
{#if $profile?.banner}
|
||||
<img src={$profile.banner} alt="" class="h-28 w-full object-cover sm:h-32 md:h-40" />
|
||||
{:else}
|
||||
<div class="h-28 w-full bg-linear-to-br from-base-300 to-base-100 sm:h-32 md:h-40"></div>
|
||||
{/if}
|
||||
{#if isSelf}
|
||||
<Button
|
||||
class="btn btn-neutral btn-sm absolute top-2 right-2 sm:top-3 sm:right-3"
|
||||
disabled={bannerLoading}
|
||||
onclick={openBannerPicker}>
|
||||
<Icon icon={GallerySend} size={4} />
|
||||
<span class="hidden sm:inline">Change banner</span>
|
||||
</Button>
|
||||
<input
|
||||
bind:this={bannerInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onchange={onBannerChange} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="relative border-b border-base-300 px-4 pb-4 sm:px-3 sm:pb-5">
|
||||
<div class="-mt-8 sm:-mt-10">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
||||
<div class="w-fit shrink-0">
|
||||
{#if isSelf}
|
||||
<Button
|
||||
class="w-fit rounded-full sm:border-4 sm:border-base-200 sm:bg-base-200"
|
||||
onclick={startEdit}>
|
||||
<ProfileCircle pubkey={target} size={16} class="sm:hidden" />
|
||||
<ProfileCircle pubkey={target} size={20} class="hidden sm:block" />
|
||||
</Button>
|
||||
{:else}
|
||||
<div class="w-fit rounded-full sm:border-4 sm:border-base-200 sm:bg-base-200">
|
||||
<ProfileCircle pubkey={target} size={16} class="sm:hidden" />
|
||||
<ProfileCircle pubkey={target} size={20} class="hidden sm:block" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-3 sm:gap-2 sm:pt-14">
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-x-4">
|
||||
<h1 class="min-w-0 text-xl leading-tight font-bold sm:text-2xl">
|
||||
{$profileDisplay}
|
||||
</h1>
|
||||
|
||||
{#if (isSelf || $pubkey) && $profile}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isSelf}
|
||||
<Button
|
||||
class="btn btn-primary btn-md flex-1 sm:btn-sm sm:flex-none"
|
||||
onclick={startEdit}>
|
||||
<Icon icon={PenNewSquare} size={4} />
|
||||
Edit profile
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
class="btn btn-neutral btn-md flex-1 sm:btn-sm sm:flex-none"
|
||||
onclick={toggleFollow}>
|
||||
<Icon icon={UserPlus} size={4} />
|
||||
{isFollowing ? "Unfollow" : "Follow"}
|
||||
</Button>
|
||||
<Button
|
||||
class="btn btn-primary btn-md flex-1 sm:btn-sm sm:flex-none"
|
||||
onclick={openChat}>
|
||||
<Icon icon={Letter} size={4} />
|
||||
Message
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<div class="relative shrink-0">
|
||||
<Button class="btn btn-circle btn-ghost btn-sm" onclick={toggleMenu}>
|
||||
<Icon icon={MenuDots} />
|
||||
</Button>
|
||||
{#if showMenu}
|
||||
<Popover hideOnClick onClose={closeMenu}>
|
||||
<ul
|
||||
transition:fly
|
||||
class="bg-alt menu absolute right-0 z-popover w-48 gap-1 rounded-box p-2 shadow-md">
|
||||
<li>
|
||||
<Button onclick={showInfo}>
|
||||
<Icon icon={Code2} />
|
||||
User Details
|
||||
</Button>
|
||||
</li>
|
||||
{#if isSelf}
|
||||
<li>
|
||||
<Button onclick={openSettings}>
|
||||
<Icon icon={Settings} />
|
||||
Account Settings
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 text-sm leading-none opacity-75">
|
||||
<span>{displayPubkey(target)}</span>
|
||||
<Button onclick={copyNpub} class="btn btn-ghost btn-xs h-5 min-h-5 w-5 p-0">
|
||||
<Icon size={3} icon={Copy} />
|
||||
</Button>
|
||||
<Button onclick={showQr} class="btn btn-ghost btn-xs h-5 min-h-5 w-5 p-0">
|
||||
<Icon size={3} icon={QrCode} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if website}
|
||||
<Link
|
||||
external
|
||||
href={websiteHref}
|
||||
class="link link-primary row-2 w-fit text-sm font-medium">
|
||||
<Icon icon={LinkRound} size={4} />
|
||||
{website}
|
||||
<Icon icon={SquareArrowRight} size={4} />
|
||||
</Link>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-neutral inline-flex h-6 items-center gap-1.5 border-0">
|
||||
<Icon icon={Shield} size={3} />
|
||||
Trust score {displayScore}
|
||||
</span>
|
||||
{#if sharedSpaceUrls.length > 0}
|
||||
<button
|
||||
class="badge badge-neutral inline-flex h-6 items-center gap-1.5 border-0"
|
||||
onclick={showSpacesTab}>
|
||||
<Icon icon={UsersGroup} size={3} />
|
||||
{sharedSpaceUrls.length} shared {sharedSpaceUrls.length === 1
|
||||
? "space"
|
||||
: "spaces"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="sticky top-0 z-10 border-b border-base-300 bg-base-200/90 px-4 backdrop-blur-sm sm:px-3">
|
||||
<div
|
||||
role="tablist"
|
||||
class="tabs tabs-bordered -mb-px flex w-full justify-between bg-transparent sm:justify-start">
|
||||
<button
|
||||
role="tab"
|
||||
class={cx("tab flex-none px-0 sm:px-4", {"tab-active": tab === "about"})}
|
||||
onclick={showAboutTab}>
|
||||
About
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
class={cx("tab flex-none px-0 sm:px-4", {"tab-active": tab === "notes"})}
|
||||
onclick={showNotesTab}>
|
||||
Notes
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
class={cx("tab flex-none px-0 sm:px-4", {"tab-active": tab === "spaces"})}
|
||||
onclick={showSpacesTab}>
|
||||
Spaces
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-3 sm:px-3 sm:py-4">
|
||||
<div class="sm:pl-3">
|
||||
{#if tab === "about"}
|
||||
<div class="col-3 sm:col-4">
|
||||
<ProfileInfo pubkey={target} />
|
||||
<div class="col-3 xl:hidden">
|
||||
<ProfileTrust pubkey={target} />
|
||||
<ProfileSharedSpaces pubkey={target} limit={3} onViewAll={showSpacesTab} />
|
||||
</div>
|
||||
<ProfilePinnedNotes
|
||||
pubkey={target}
|
||||
limit={2}
|
||||
editable={isSelf}
|
||||
onViewAll={showNotesTab} />
|
||||
</div>
|
||||
{:else if tab === "notes"}
|
||||
{#if isSelf}
|
||||
<p class="mb-4 text-sm opacity-75">
|
||||
Notes are public posts on your write relays. Pin notes to highlight them on your
|
||||
profile, or manage relays in
|
||||
<Button class="link link-primary" onclick={openRelaySettings}>relay settings</Button
|
||||
>.
|
||||
</p>
|
||||
{/if}
|
||||
<ProfilePageNotes pubkey={target} editable={isSelf} />
|
||||
{:else}
|
||||
{#if isSelf}
|
||||
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-sm opacity-75">Spaces come from your published group list.</p>
|
||||
<Button class="btn btn-neutral btn-sm w-full sm:w-auto" onclick={openSpaces}>
|
||||
Manage spaces
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<ProfilePageSpaces pubkey={target} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="hidden w-80 shrink-0 xl:block xl:border-l xl:border-base-300 xl:pl-4">
|
||||
<div class="col-3">
|
||||
<ProfileTrust pubkey={target} />
|
||||
<ProfileSharedSpaces pubkey={target} limit={3} onViewAll={showSpacesTab} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {sortBy, uniqBy} from "@welshman/lib"
|
||||
import {feedFromFilter} from "@welshman/feeds"
|
||||
import {NOTE, getReplyTags} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {makeFeedController} from "@welshman/app"
|
||||
import {createScroller} from "@lib/html"
|
||||
import {fly} from "@lib/transition"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import NoteItem from "@app/components/NoteItem.svelte"
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
const {pubkey, editable = false}: Props = $props()
|
||||
|
||||
const ctrl = makeFeedController({
|
||||
useWindowing: true,
|
||||
feed: feedFromFilter({kinds: [NOTE], authors: [pubkey]}),
|
||||
onEvent: (event: TrustedEvent) => {
|
||||
if (getReplyTags(event.tags).replies.length === 0) {
|
||||
buffer.push(event)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
let element: Element | undefined = $state()
|
||||
let events: TrustedEvent[] = $state([])
|
||||
let buffer: TrustedEvent[] = []
|
||||
|
||||
onMount(() => {
|
||||
const scroller = createScroller({
|
||||
element: element!,
|
||||
delay: 300,
|
||||
threshold: 3000,
|
||||
onScroll: () => {
|
||||
buffer = uniqBy(
|
||||
e => e.id,
|
||||
sortBy(e => -e.created_at, buffer),
|
||||
)
|
||||
|
||||
events = uniqBy(e => e.id, [...events, ...buffer.splice(0, 5)])
|
||||
|
||||
if (buffer.length < 50) {
|
||||
ctrl.load(50)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return () => scroller.stop()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="col-4" bind:this={element}>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each events as event (event.id)}
|
||||
<div in:fly>
|
||||
<NoteItem {event} {editable} />
|
||||
</div>
|
||||
{/each}
|
||||
<p class="center my-12 flex">
|
||||
<Spinner loading />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,47 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import {deriveGroupList, getSpaceUrlsFromGroupList, groupListPubkeysByUrl} from "@app/groups"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
const {pubkey}: Props = $props()
|
||||
|
||||
const groupList = deriveGroupList(pubkey)
|
||||
const spaceUrls = $derived(getSpaceUrlsFromGroupList($groupList))
|
||||
</script>
|
||||
|
||||
<div class="col-2">
|
||||
{#each spaceUrls as url (url)}
|
||||
{@const count = $groupListPubkeysByUrl.get(url)?.size || 0}
|
||||
<div
|
||||
class="card2 card2-sm bg-alt flex flex-col gap-3 border border-base-300 sm:flex-row sm:items-center">
|
||||
<RelayIcon {url} size={10} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<RelayName {url} class="font-medium" />
|
||||
<p class="text-sm opacity-75">
|
||||
{#if count >= 1000}
|
||||
{(count / 1000).toFixed(1).replace(/\.0$/, "")}K members
|
||||
{:else}
|
||||
{count} {count === 1 ? "member" : "members"}
|
||||
{/if}
|
||||
</p>
|
||||
<p class="ellipsize text-xs opacity-60">{url}</p>
|
||||
</div>
|
||||
<Link class="btn btn-primary btn-sm w-full sm:w-auto" href={makeSpacePath(url)}>
|
||||
Go to space
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Link>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card2 bg-alt border border-base-300 text-center">
|
||||
<p class="opacity-75">No spaces found for this user.</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,88 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {sortBy} from "@welshman/lib"
|
||||
import {getListTags, getEventTagValues} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {derivePinList, repository} from "@welshman/app"
|
||||
import {Router} from "@welshman/router"
|
||||
import {load} from "@welshman/net"
|
||||
import {fly} from "@lib/transition"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import NoteItem from "@app/components/NoteItem.svelte"
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
limit?: number
|
||||
onViewAll?: () => void
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
const {pubkey, limit, onViewAll, editable = false}: Props = $props()
|
||||
|
||||
const pinList = derivePinList(pubkey)
|
||||
const pinnedIds = $derived(getEventTagValues(getListTags($pinList)))
|
||||
const displayIds = $derived(limit ? pinnedIds.slice(0, limit) : pinnedIds)
|
||||
|
||||
const pinnedEvents = $derived.by(() => {
|
||||
return sortBy(
|
||||
e => -pinnedIds.indexOf(e.id),
|
||||
displayIds
|
||||
.map(id => repository.getEvent(id))
|
||||
.filter((event): event is TrustedEvent => Boolean(event)),
|
||||
)
|
||||
})
|
||||
|
||||
let loading = $state(pinnedIds.length > 0)
|
||||
|
||||
onMount(() => {
|
||||
if (pinnedIds.length === 0) {
|
||||
loading = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const missing = pinnedIds.filter(id => !repository.getEvent(id))
|
||||
|
||||
if (missing.length === 0) {
|
||||
loading = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
load({
|
||||
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
|
||||
filters: [{ids: missing}],
|
||||
onEvent: () => {
|
||||
loading = !pinnedIds.every(id => repository.getEvent(id))
|
||||
},
|
||||
onClose: () => {
|
||||
loading = false
|
||||
},
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if pinnedIds.length > 0}
|
||||
<div class="col-4 border-t border-base-300 pt-4">
|
||||
<strong>Pinned notes</strong>
|
||||
{#if loading && pinnedEvents.length === 0}
|
||||
<p class="center flex py-8">
|
||||
<Spinner loading />
|
||||
</p>
|
||||
{:else}
|
||||
<div class="col-2">
|
||||
{#each pinnedEvents as event (event.id)}
|
||||
<div in:fly>
|
||||
<NoteItem {event} {editable} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if onViewAll && pinnedIds.length > (limit || pinnedIds.length)}
|
||||
<button class="link link-primary row-2 text-sm" onclick={onViewAll}>
|
||||
View all pinned notes
|
||||
<span aria-hidden="true">→</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import QRCode from "@app/components/QRCode.svelte"
|
||||
|
||||
type Props = {
|
||||
code: string
|
||||
}
|
||||
|
||||
const {code}: Props = $props()
|
||||
|
||||
const back = () => history.back()
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
<ModalBody>
|
||||
<div class="col-4 items-center text-center">
|
||||
<strong>Profile QR Code</strong>
|
||||
<QRCode {code} class="max-w-64" />
|
||||
<p class="break-all text-sm opacity-75">{code}</p>
|
||||
<p class="text-sm opacity-75">Tap the QR code to copy this npub.</p>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onclick={back} class="hidden md:btn md:btn-link">
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button onclick={back} class="btn btn-neutral">Close</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
@@ -1,74 +0,0 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import {
|
||||
groupListPubkeysByUrl,
|
||||
userSpaceUrls,
|
||||
deriveGroupList,
|
||||
getSpaceUrlsFromGroupList,
|
||||
} from "@app/groups"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
limit?: number
|
||||
onViewAll?: () => void
|
||||
class?: string
|
||||
}
|
||||
|
||||
const {pubkey, limit, onViewAll, ...props}: Props = $props()
|
||||
|
||||
const groupList = deriveGroupList(pubkey)
|
||||
const spaceUrls = $derived(getSpaceUrlsFromGroupList($groupList))
|
||||
const sharedSpaceUrls = $derived($userSpaceUrls.filter(url => spaceUrls.includes(url)))
|
||||
const displayUrls = $derived(limit ? sharedSpaceUrls.slice(0, limit) : sharedSpaceUrls)
|
||||
</script>
|
||||
|
||||
<div class={cx("card2 bg-alt col-3 border border-base-300 max-sm:p-5 sm:col-4", props.class)}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="row-2">
|
||||
<Icon icon={UsersGroup} size={5} />
|
||||
<strong>Shared spaces</strong>
|
||||
</div>
|
||||
<span class="badge badge-neutral">{sharedSpaceUrls.length}</span>
|
||||
</div>
|
||||
{#if displayUrls.length > 0}
|
||||
<div class="col-2 border-t border-base-300 pt-4 sm:pt-4">
|
||||
{#each displayUrls as url (url)}
|
||||
{@const count = $groupListPubkeysByUrl.get(url)?.size || 0}
|
||||
<Link
|
||||
href={makeSpacePath(url)}
|
||||
class="row-2 rounded-box border border-base-300 p-4 transition-colors hover:bg-base-300/30 sm:p-3">
|
||||
<RelayIcon {url} size={8} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<RelayName {url} class="ellipsize text-sm font-medium" />
|
||||
<p class="text-xs opacity-75">
|
||||
{#if count >= 1000}
|
||||
{(count / 1000).toFixed(1).replace(/\.0$/, "")}K members
|
||||
{:else}
|
||||
{count} {count === 1 ? "member" : "members"}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
{/each}
|
||||
</div>
|
||||
{#if onViewAll && sharedSpaceUrls.length > (limit || sharedSpaceUrls.length)}
|
||||
<button
|
||||
class="link link-primary row-2 border-t border-base-300 pt-4 text-sm max-sm:pt-4"
|
||||
onclick={onViewAll}>
|
||||
View all shared spaces
|
||||
<Icon icon={AltArrowRight} size={4} />
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="border-t border-base-300 pt-4 text-sm opacity-75 max-sm:pt-4">
|
||||
No shared spaces yet.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,72 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {clamp, uniq} from "@welshman/lib"
|
||||
import {
|
||||
pubkey,
|
||||
followLists,
|
||||
deriveUserWotScore,
|
||||
deriveProfileDisplay,
|
||||
deriveFollowList,
|
||||
getFollows,
|
||||
} from "@welshman/app"
|
||||
import Shield from "@assets/icons/shield-minimalistic.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
const {pubkey: target}: Props = $props()
|
||||
|
||||
const score = deriveUserWotScore(target)
|
||||
const profileDisplay = deriveProfileDisplay(target)
|
||||
const targetFollowList = deriveFollowList(target)
|
||||
|
||||
const mutualFollows = $derived.by(() => {
|
||||
const viewer = $pubkey
|
||||
void $followLists
|
||||
void $targetFollowList
|
||||
|
||||
if (!viewer) return []
|
||||
|
||||
const viewerFollows = new Set(getFollows(viewer))
|
||||
|
||||
return uniq(
|
||||
getFollows(target).filter(pk => pk !== viewer && pk !== target && viewerFollows.has(pk)),
|
||||
)
|
||||
})
|
||||
const displayScore = $derived(Math.round(clamp([0, 100], $score)))
|
||||
const progress = $derived(displayScore)
|
||||
|
||||
const trustMessage = $derived.by(() => {
|
||||
if (displayScore >= 70) return "This user is highly trusted in your network."
|
||||
if (displayScore >= 30) return "This user has some trust in your network."
|
||||
|
||||
return "This user is not well known in your network."
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="card2 bg-alt col-3 border border-base-300 max-sm:p-5 sm:col-4">
|
||||
<div class="row-2">
|
||||
<Icon icon={Shield} size={5} />
|
||||
<strong>Reputation</strong>
|
||||
</div>
|
||||
<div class="col-2 border-t border-base-300 pt-4 sm:pt-4">
|
||||
<div class="flex items-end justify-between gap-2">
|
||||
<span class="text-sm opacity-75">Trust score</span>
|
||||
<span class="text-lg font-semibold">{displayScore} / 100</span>
|
||||
</div>
|
||||
<progress class="progress progress-primary w-full" value={progress} max="100"></progress>
|
||||
<p class="text-sm opacity-75">{trustMessage}</p>
|
||||
</div>
|
||||
{#if mutualFollows.length > 0}
|
||||
<div class="col-2 border-t border-base-300 pt-4 sm:pt-4">
|
||||
<p class="text-sm font-medium">Mutual follows</p>
|
||||
<ProfileCircles pubkeys={mutualFollows} limit={5} />
|
||||
<p class="text-sm opacity-75">
|
||||
{mutualFollows.length}
|
||||
{mutualFollows.length === 1 ? "person" : "people"} you and {$profileDisplay} both follow.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -2,8 +2,14 @@
|
||||
const {current, total}: {current: number; total: number} = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex w-full">
|
||||
<div class="flex w-full gap-1.5">
|
||||
{#each Array(total) as _, i}
|
||||
<div class="h-1 flex-1 transition-colors {i < current ? 'bg-primary' : 'bg-base-300'}"></div>
|
||||
<div
|
||||
class="h-2 flex-1 rounded-full transition-colors duration-300 {i < current
|
||||
? 'bg-primary'
|
||||
: i === current
|
||||
? 'bg-primary/40 motion-safe:animate-pulse'
|
||||
: 'bg-base-300'}">
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -157,15 +157,15 @@
|
||||
data-tip={tooltip}
|
||||
class={cx(
|
||||
reactionClass,
|
||||
"flex-inline btn btn-outline btn-neutral btn-xs flex items-center gap-1 rounded-full text-xs font-normal bg-alt",
|
||||
"flex-inline btn btn-xs flex items-center gap-1 rounded-full border text-xs font-normal transition-transform motion-safe:hover:scale-110 motion-safe:active:scale-95",
|
||||
{
|
||||
tooltip: !noTooltip && !isMobile,
|
||||
"border-neutral-content/20": !isOwn,
|
||||
"btn-primary": isOwn,
|
||||
"bg-alt border-base-content/15": !isOwn,
|
||||
"border-primary/50 bg-primary/15 text-primary": isOwn,
|
||||
},
|
||||
)}>
|
||||
<Reaction event={zaps[0].request} />
|
||||
<span>{amount}</span>
|
||||
<span class="font-semibold">{amount}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each groupedReactions.entries() as [key, events]}
|
||||
@@ -179,17 +179,17 @@
|
||||
data-tip={tooltip}
|
||||
class={cx(
|
||||
reactionClass,
|
||||
"flex-inline btn btn-outline btn-neutral btn-xs gap-1 rounded-full font-normal bg-alt",
|
||||
"flex-inline btn btn-xs gap-1 rounded-full border font-normal transition-transform motion-safe:hover:scale-110 motion-safe:active:scale-95",
|
||||
{
|
||||
tooltip: !noTooltip && !isMobile,
|
||||
"border-neutral-content/20": !isOwn,
|
||||
"btn-primary": isOwn,
|
||||
"bg-alt border-base-content/15": !isOwn,
|
||||
"border-primary/50 bg-primary/15 text-primary": isOwn,
|
||||
},
|
||||
)}
|
||||
onclick={stopPropagation(preventDefault(onClick))}>
|
||||
<Reaction event={events[0]} />
|
||||
{#if events.length > 1}
|
||||
<span>{events.length}</span>
|
||||
<span class="font-semibold">{events.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
|
||||
import {getColor} from "@app/theme"
|
||||
import {makeRoomPath, makeSpaceChatPath} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
@@ -25,7 +26,9 @@
|
||||
const onClick = () => goto(h ? makeRoomPath(url, h) : makeSpaceChatPath(url))
|
||||
</script>
|
||||
|
||||
<Button class="cv card2 bg-alt shadow-md" onclick={onClick}>
|
||||
<Button
|
||||
class="cv card2 bg-alt shadow-soft block w-full transition-all motion-safe:hover:-translate-y-0.5"
|
||||
onclick={onClick}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
{#if h}
|
||||
@@ -39,7 +42,10 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<ProfileCircle pubkey={event.pubkey} size={10} />
|
||||
<ProfileCircle
|
||||
pubkey={event.pubkey}
|
||||
size={10}
|
||||
style="box-shadow: 0 0 0 2px {getColor(event.pubkey)}" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<NoteContentMinimal {event} />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {deriveRelay} from "@welshman/app"
|
||||
import RemoteControllerMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl"
|
||||
import {getColor} from "@app/theme"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
|
||||
type Props = {
|
||||
@@ -12,10 +13,22 @@
|
||||
const {url, size = 7, ...props}: Props = $props()
|
||||
|
||||
const relay = deriveRelay(url)
|
||||
const px = size * 4
|
||||
const color = getColor(url)
|
||||
const letter = (url.replace(/^wss?:\/\//, "").replace(/^www\./, "")[0] || "?").toUpperCase()
|
||||
</script>
|
||||
|
||||
{#if $relay?.icon}
|
||||
<ImageIcon {size} alt="" src={$relay?.icon} class={props.class} />
|
||||
<ImageIcon {size} alt="" src={$relay.icon} class={cx(props.class, "squircle")} />
|
||||
{:else}
|
||||
<ImageIcon size={size - 2} alt="" src={RemoteControllerMinimalistic} class={props.class} />
|
||||
<!-- Lettered workspace tile (Slack/Discord-style) colored by the relay url. -->
|
||||
<div
|
||||
class={cx(
|
||||
props.class,
|
||||
"squircle font-display flex shrink-0 items-center justify-center font-bold text-white uppercase",
|
||||
)}
|
||||
style="width:{px}px;height:{px}px;font-size:{px *
|
||||
0.42}px;background-image:linear-gradient(135deg,{color},color-mix(in oklab,{color},#000 28%))">
|
||||
{letter}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
</div>
|
||||
<Button
|
||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
||||
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
|
||||
class="center tooltip tooltip-left bg-primary text-primary-content absolute top-[7px] right-[7px] h-11 w-11 min-w-11 scale-90 rounded-full transition-transform motion-safe:hover:scale-100"
|
||||
disabled={$uploading}
|
||||
onclick={submit}>
|
||||
<Icon icon={Plane} />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Volume from "@assets/icons/volume.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
import {getColor} from "@app/theme"
|
||||
import {deriveRoom} from "@app/groups"
|
||||
|
||||
interface Props {
|
||||
@@ -16,18 +17,26 @@
|
||||
|
||||
const room = deriveRoom(url, h)
|
||||
const isVoiceRoom = $derived($room.livekit)
|
||||
const px = size * 4
|
||||
// Voice rooms read warm/orange; text rooms get a per-room identity color.
|
||||
const color = $derived(isVoiceRoom ? "var(--color-secondary)" : getColor(h))
|
||||
</script>
|
||||
|
||||
{#if isVoiceRoom}
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<Icon size={size + 1} icon={Volume} />
|
||||
<Icon size={size + 1} icon={Volume} class="text-secondary" />
|
||||
{#if $room.picture}
|
||||
<span class="text-base">/</span>
|
||||
<ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" />
|
||||
<ImageIcon src={$room.picture} {size} alt="" class="squircle shadow-sm" />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if $room.picture}
|
||||
<ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" />
|
||||
<ImageIcon src={$room.picture} {size} alt="" class="squircle shadow-sm" />
|
||||
{:else}
|
||||
<Icon icon={fallbackIcon} {size} />
|
||||
<!-- Colored room tile with the type glyph in white. -->
|
||||
<div
|
||||
class="squircle flex shrink-0 items-center justify-center text-white"
|
||||
style="width:{px}px;height:{px}px;background-image:linear-gradient(135deg,{color},color-mix(in oklab,{color},#000 28%))">
|
||||
<Icon icon={fallbackIcon} size={Math.max(3, size - 1)} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {readable} from "svelte/store"
|
||||
import {
|
||||
hash,
|
||||
gte,
|
||||
now,
|
||||
displayList,
|
||||
formatTimestampAsTime,
|
||||
formatTimestampAsDate,
|
||||
} from "@welshman/lib"
|
||||
import {gte, now, displayList, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {MESSAGE, COMMENT, getTag} from "@welshman/util"
|
||||
import {
|
||||
@@ -35,7 +28,7 @@
|
||||
import RoomItemMenuButton from "@app/components/RoomItemMenuButton.svelte"
|
||||
import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte"
|
||||
import RoomItemContent from "@app/components/RoomItemContent.svelte"
|
||||
import {colors} from "@app/theme"
|
||||
import {getColor} from "@app/theme"
|
||||
import {ENABLE_ZAPS} from "@app/env"
|
||||
import {deriveEventsForUrl, deriveEvent} from "@app/repository"
|
||||
import {publishDelete} from "@app/deletes"
|
||||
@@ -60,7 +53,7 @@
|
||||
const today = formatTimestampAsDate(now())
|
||||
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
|
||||
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
|
||||
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
|
||||
const colorValue = getColor(event.pubkey)
|
||||
|
||||
const qTag = getTag("q", event.tags)
|
||||
const isQuoteOnly = Boolean(
|
||||
@@ -95,10 +88,7 @@
|
||||
<div class="flex w-full gap-3 overflow-auto">
|
||||
{#if showPubkey}
|
||||
<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} />
|
||||
<ProfileCircle pubkey={event.pubkey} style="box-shadow: 0 0 0 2px {colorValue}" size={8} />
|
||||
</Button>
|
||||
{:else}
|
||||
<div class="w-8 shrink-0"></div>
|
||||
@@ -155,8 +145,9 @@
|
||||
</div>
|
||||
{#if !isMobile}
|
||||
<button
|
||||
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}>
|
||||
class="join bg-base-100 shadow-soft absolute right-2 top-0.5 translate-y-1 rounded-full p-0.5 text-xs opacity-0 transition-all"
|
||||
class:group-hover:opacity-100={!isMobile}
|
||||
class:group-hover:translate-y-0={!isMobile}>
|
||||
{#if ENABLE_ZAPS}
|
||||
<RoomItemZapButton {url} {event} />
|
||||
{/if}
|
||||
|
||||
@@ -95,17 +95,19 @@
|
||||
|
||||
<Modal>
|
||||
<ModalBody>
|
||||
<h1 class="heading">Join {PLATFORM_NAME}</h1>
|
||||
<h1 class="heading">Join <span class="brand">{PLATFORM_NAME}</span></h1>
|
||||
<p class="m-auto max-w-sm text-center">
|
||||
Censorship resistant digital spaces for communities. Meet new people, own your identity.
|
||||
</p>
|
||||
{#if hasPomade}
|
||||
<Button onclick={flows.email.start} class="btn btn-primary">
|
||||
<Button onclick={flows.email.start} class="btn btn-primary rounded-full">
|
||||
<Icon icon={Letter} />
|
||||
Sign up with email
|
||||
</Button>
|
||||
{/if}
|
||||
<Button onclick={flows.nostr.start} class="btn {hasPomade ? 'btn-neutral' : 'btn-primary'}">
|
||||
<Button
|
||||
onclick={flows.nostr.start}
|
||||
class="btn rounded-full {hasPomade ? 'btn-neutral' : 'btn-primary'}">
|
||||
<Icon icon={Key} />
|
||||
Generate a key
|
||||
</Button>
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
import {preventDefault} from "@lib/html"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import HomeSmile from "@assets/icons/home-smile.svg?dataurl"
|
||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import ProgressBar from "@app/components/ProgressBar.svelte"
|
||||
|
||||
@@ -24,9 +23,12 @@
|
||||
|
||||
<Modal tag="form" onsubmit={preventDefault(next)}>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>You're all set!</ModalTitle>
|
||||
</ModalHeader>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="center bg-primary/15 text-primary size-16 rounded-full motion-safe:animate-pop">
|
||||
<Icon icon={CheckCircle} size={9} />
|
||||
</div>
|
||||
<h1 class="heading">You're all set!</h1>
|
||||
</div>
|
||||
<p>
|
||||
You've created your profile, saved your keys, and now you're ready to start chatting — all
|
||||
without asking permission!
|
||||
|
||||
@@ -29,22 +29,20 @@
|
||||
<Button onclick={back} class="place-self-start pr-3 md:hidden">
|
||||
<Icon icon={ArrowLeft} size={7} />
|
||||
</Button>
|
||||
<div class="flex grow items-center justify-between gap-4">
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<div class="flex min-w-0 items-start gap-2">
|
||||
<RelayIcon {url} size={5} class="shrink-0 rounded-full md:hidden" />
|
||||
<div class="hidden shrink-0 md:flex md:items-center">
|
||||
<div class="ellipsize whitespace-nowrap flex grow items-center justify-between gap-4">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex gap-2 items-center">
|
||||
<RelayIcon {url} size={5} class="rounded-full md:hidden" />
|
||||
<div class="hidden md:contents">
|
||||
{@render leading?.()}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
{@render title?.()}
|
||||
</div>
|
||||
{@render title?.()}
|
||||
</div>
|
||||
<div class="text-xs text-primary pl-7 md:hidden">
|
||||
<div class="text-xs text-primary md:hidden">
|
||||
{displayRelayUrl(url)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<div class="flex gap-2 items-start">
|
||||
{@render action?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
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="font-display relative flex items-center gap-1">
|
||||
<RelayName {url} class="ellipsize" />
|
||||
<div
|
||||
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
|
||||
@@ -311,7 +311,7 @@
|
||||
<div
|
||||
class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav">
|
||||
<VoiceWidget />
|
||||
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
|
||||
<Button class="btn btn-ghost btn-sm bg-base-100 h-10 rounded-full" onclick={showDetail}>
|
||||
<SocketStatusIndicator {url} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import ThreadBoardItem from "@app/components/ThreadBoardItem.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
h: string
|
||||
threads: TrustedEvent[]
|
||||
}
|
||||
|
||||
const {url, h, threads}: Props = $props()
|
||||
</script>
|
||||
|
||||
<section class="overflow-hidden rounded-box border border-base-content/15 bg-base-100 shadow-sm">
|
||||
<header
|
||||
class="flex items-center justify-between gap-2 border-b border-base-content/15 bg-base-200/70 px-4 py-2.5">
|
||||
<h2 class="text-sm font-bold sm:text-base">
|
||||
{#if h}
|
||||
#<RoomName {url} {h} />
|
||||
{:else}
|
||||
General
|
||||
{/if}
|
||||
</h2>
|
||||
<span class="text-xs opacity-60">
|
||||
{threads.length}
|
||||
{threads.length === 1 ? "topic" : "topics"}
|
||||
</span>
|
||||
</header>
|
||||
<div
|
||||
class="hidden border-b border-base-content/10 bg-base-200/40 px-4 py-2 text-xs font-bold uppercase tracking-wide opacity-60 sm:grid sm:grid-cols-[1fr_8rem_5rem_8rem] sm:gap-x-4">
|
||||
<span>Topic</span>
|
||||
<span>Author</span>
|
||||
<span class="text-center">Replies</span>
|
||||
<span class="text-right">Last post</span>
|
||||
</div>
|
||||
{#each threads as event (event.id)}
|
||||
<ThreadBoardItem {url} {event} />
|
||||
{/each}
|
||||
</section>
|
||||
@@ -1,48 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {formatTimestamp, max} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {COMMENT, getTagValue} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
import {deriveEventsForUrl} from "@app/repository"
|
||||
import {makeThreadPath} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
|
||||
const replies = deriveEventsForUrl(url, filters)
|
||||
const replyCount = $derived($replies.length)
|
||||
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
|
||||
const title = getTagValue("title", event.tags)
|
||||
</script>
|
||||
|
||||
<Link
|
||||
href={makeThreadPath(url, event.id)}
|
||||
class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-1 border-b border-base-content/10 px-3 py-3 transition-colors hover:bg-base-200/50 sm:grid-cols-[1fr_8rem_5rem_8rem] sm:items-center sm:gap-x-4 sm:px-4">
|
||||
<div class="col-span-2 min-w-0 sm:col-span-1">
|
||||
<p class="ellipsize text-sm font-bold sm:text-base">{title || "Untitled thread"}</p>
|
||||
<p class="ellipsize mt-0.5 text-xs opacity-60 sm:hidden">
|
||||
by <ProfileName pubkey={event.pubkey} {url} />
|
||||
</p>
|
||||
</div>
|
||||
<div class="hidden items-center gap-2 sm:flex">
|
||||
<ProfileCircle pubkey={event.pubkey} {url} size={6} />
|
||||
<span class="ellipsize text-sm">
|
||||
<ProfileName pubkey={event.pubkey} {url} />
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-right text-xs opacity-75 sm:text-center sm:text-sm">
|
||||
<span class="opacity-60 sm:hidden">Replies · </span>
|
||||
{replyCount}
|
||||
</p>
|
||||
<p class="text-right text-xs opacity-75 sm:text-sm">
|
||||
<span class="opacity-60 sm:hidden">Last · </span>
|
||||
{formatTimestamp(lastActive)}
|
||||
</p>
|
||||
</Link>
|
||||
@@ -1,66 +0,0 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import DoubleAltArrowLeft from "@assets/icons/double-alt-arrow-left.svg?dataurl"
|
||||
import DoubleAltArrowRight from "@assets/icons/double-alt-arrow-right.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
||||
type Props = {
|
||||
page: number
|
||||
pageCount: number
|
||||
onPage: (page: number) => void
|
||||
}
|
||||
|
||||
const {page, pageCount, onPage}: Props = $props()
|
||||
|
||||
const goFirst = () => onPage(1)
|
||||
const goPrev = () => onPage(page - 1)
|
||||
const goNext = () => onPage(page + 1)
|
||||
const goLast = () => onPage(pageCount)
|
||||
const goToPage = (target: number) => onPage(target)
|
||||
|
||||
const pages = $derived.by(() => {
|
||||
if (pageCount <= 7) {
|
||||
return Array.from({length: pageCount}, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
const result = new Set<number>([1, pageCount, page])
|
||||
|
||||
if (page > 2) result.add(page - 1)
|
||||
if (page < pageCount - 1) result.add(page + 1)
|
||||
if (page > 3) result.add(page - 2)
|
||||
if (page < pageCount - 2) result.add(page + 2)
|
||||
|
||||
return Array.from(result).sort((a, b) => a - b)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center gap-3 border-t border-base-content/10 py-4">
|
||||
<p class="text-sm opacity-75">Page {page} of {pageCount}</p>
|
||||
<div class="join">
|
||||
<Button class="btn join-item btn-sm" disabled={page <= 1} onclick={goFirst}>
|
||||
<Icon icon={DoubleAltArrowLeft} size={4} />
|
||||
</Button>
|
||||
<Button class="btn join-item btn-sm" disabled={page <= 1} onclick={goPrev}>
|
||||
<Icon icon={AltArrowLeft} size={4} />
|
||||
</Button>
|
||||
{#each pages as p, i (p)}
|
||||
{#if i > 0 && p - pages[i - 1] > 1}
|
||||
<Button class="btn join-item btn-sm btn-disabled" disabled>…</Button>
|
||||
{/if}
|
||||
<Button
|
||||
class={cx("btn join-item btn-sm", page === p && "btn-primary")}
|
||||
onclick={() => goToPage(p)}>
|
||||
{p}
|
||||
</Button>
|
||||
{/each}
|
||||
<Button class="btn join-item btn-sm" disabled={page >= pageCount} onclick={goNext}>
|
||||
<Icon icon={AltArrowRight} size={4} />
|
||||
</Button>
|
||||
<Button class="btn join-item btn-sm" disabled={page >= pageCount} onclick={goLast}>
|
||||
<Icon icon={DoubleAltArrowRight} size={4} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,93 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {formatTimestamp} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {COMMENT} from "@welshman/util"
|
||||
import {deriveHandleForPubkey, deriveProfileDisplay, displayHandle} from "@welshman/app"
|
||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import CommentActions from "@app/components/CommentActions.svelte"
|
||||
import ThreadActions from "@app/components/ThreadActions.svelte"
|
||||
import {makeEventPermalink} from "@app/routes"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {clip} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
threadPubkey: string
|
||||
onReply: (event: TrustedEvent) => void
|
||||
}
|
||||
|
||||
const {url, event, threadPubkey, onReply}: Props = $props()
|
||||
|
||||
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
|
||||
const handle = deriveHandleForPubkey(event.pubkey)
|
||||
const isOp = event.pubkey === threadPubkey
|
||||
const isComment = event.kind === COMMENT
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
|
||||
|
||||
const copyPermalink = () => clip(makeEventPermalink(event, url))
|
||||
|
||||
const reply = () => onReply(event)
|
||||
</script>
|
||||
|
||||
<article
|
||||
id="post-{event.id}"
|
||||
data-event={event.id}
|
||||
class="border-b border-base-content/15 bg-base-100">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<aside
|
||||
class="flex shrink-0 flex-row items-center gap-3 border-b border-base-content/10 bg-base-200/50 p-3 md:w-40 md:flex-col md:items-center md:border-b-0 md:border-r md:p-4 md:text-center">
|
||||
<Button onclick={openProfile}>
|
||||
<ProfileCircle pubkey={event.pubkey} {url} size={10} class="md:size-14" />
|
||||
</Button>
|
||||
<div class="flex min-w-0 flex-col gap-1 md:items-center">
|
||||
<Button onclick={openProfile} class="text-bold ellipsize text-sm">
|
||||
{$profileDisplay}
|
||||
</Button>
|
||||
{#if $handle}
|
||||
<span class="ellipsize text-xs opacity-75">{displayHandle($handle)}</span>
|
||||
{/if}
|
||||
{#if isOp}
|
||||
<span class="badge badge-primary badge-sm">OP</span>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
<div class="flex min-w-0 grow flex-col">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-2 border-b border-base-content/10 bg-base-200/40 px-3 py-2 text-xs sm:px-4 sm:text-sm">
|
||||
<span class="opacity-75">{formatTimestamp(event.created_at)}</span>
|
||||
<Button class="btn btn-ghost btn-xs h-auto min-h-0 gap-1 px-1 py-0" onclick={copyPermalink}>
|
||||
<Icon icon={LinkRound} size={3} />
|
||||
Permalink
|
||||
</Button>
|
||||
</div>
|
||||
<div class="px-3 py-4 sm:px-4">
|
||||
{#if isComment}
|
||||
<Content showEntire {event} {url} />
|
||||
{:else}
|
||||
<NoteContent showEntire {event} {url} />
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex shrink-0 flex-col gap-2 border-t border-base-content/10 bg-base-200/20 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-4">
|
||||
<Button class="btn btn-neutral btn-xs w-fit gap-1" onclick={reply}>
|
||||
<Icon icon={Reply} size={4} />
|
||||
Reply
|
||||
</Button>
|
||||
{#if isComment}
|
||||
<CommentActions segment="threads" {event} {url} />
|
||||
{:else}
|
||||
<ThreadActions {event} {url} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -2,6 +2,8 @@
|
||||
import type {AbstractThunk} from "@welshman/app"
|
||||
import {thunkHasStatus, thunkIsComplete} from "@welshman/app"
|
||||
import {PublishStatus} from "@welshman/net"
|
||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ThunkPending from "@app/components/ThunkPending.svelte"
|
||||
import type {Toast} from "@app/toast"
|
||||
import {popToast} from "@app/toast"
|
||||
@@ -35,5 +37,8 @@
|
||||
{#if !isComplete}
|
||||
<ThunkPending {thunk} />
|
||||
{:else if !isFailure}
|
||||
<p class="text-xs opacity-75">Message sent!</p>
|
||||
<p class="flex items-center gap-1.5 text-xs opacity-80">
|
||||
<Icon icon={CheckCircle} size={4} class="text-success motion-safe:animate-pop" />
|
||||
Message sent!
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
url?: string
|
||||
url: string
|
||||
pubkey: string
|
||||
eventId?: string
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
import {clip, pushToast} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
url?: string
|
||||
url: string
|
||||
pubkey: string
|
||||
eventId?: string
|
||||
}
|
||||
|
||||
+4
-4
@@ -18,7 +18,7 @@ import {
|
||||
now,
|
||||
on,
|
||||
sortBy,
|
||||
MONTH,
|
||||
WEEK,
|
||||
YEAR,
|
||||
} from "@welshman/lib"
|
||||
import {
|
||||
@@ -122,7 +122,7 @@ export const makeFeed = ({
|
||||
const controller = new AbortController()
|
||||
const events = writable<TrustedEvent[]>([])
|
||||
|
||||
let interval = int(MONTH)
|
||||
let interval = int(WEEK)
|
||||
let buffer = sortEventsDesc(getEventsForUrl(url, filters))
|
||||
let backwardWindow = [at - interval, at]
|
||||
let forwardWindow = [at, at + interval]
|
||||
@@ -213,7 +213,7 @@ export const makeFeed = ({
|
||||
if (events.length === 0) {
|
||||
interval = Math.round(interval * 1.1)
|
||||
} else {
|
||||
interval = int(MONTH)
|
||||
interval = int(WEEK)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ export const makeCalendarFeed = ({
|
||||
element: HTMLElement
|
||||
onExhausted?: () => void
|
||||
}) => {
|
||||
const interval = int(5, MONTH)
|
||||
const interval = int(5, WEEK)
|
||||
const controller = new AbortController()
|
||||
|
||||
let exhaustedScrollers = 0
|
||||
|
||||
+1
-18
@@ -20,7 +20,7 @@ import {
|
||||
getRelaysFromList,
|
||||
} from "@welshman/util"
|
||||
import {makeChatId} from "@app/chats"
|
||||
import {entityLink, PLATFORM_URL} from "@app/env"
|
||||
import {entityLink} from "@app/env"
|
||||
import {encodeRelay, hasNip29} from "@app/relays"
|
||||
import {DM_KINDS} from "@app/content"
|
||||
import {ROOM} from "@app/groups"
|
||||
@@ -44,12 +44,6 @@ export const setupHistory = () =>
|
||||
}
|
||||
})
|
||||
|
||||
// Profiles
|
||||
|
||||
export const makeProfilePath = (pubkey: string) => `/people/${nip19.npubEncode(pubkey)}`
|
||||
|
||||
export const goToProfile = (pubkey: string) => goto(makeProfilePath(pubkey))
|
||||
|
||||
// Chat
|
||||
|
||||
export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}`
|
||||
@@ -217,17 +211,6 @@ export const getEventPath = (event: TrustedEvent, urls: string[]) => {
|
||||
return entityLink(nip19.neventEncode({id: event.id, relays: urls}))
|
||||
}
|
||||
|
||||
export const makeEventPermalink = (event: TrustedEvent, url?: string) => {
|
||||
const urls = url ? [url] : Array.from(tracker.getRelays(event.id))
|
||||
const path = getEventPath(event, urls)
|
||||
|
||||
if (path.includes("://")) {
|
||||
return path
|
||||
}
|
||||
|
||||
return `${PLATFORM_URL}${path}#${nip19.neventEncode({id: event.id, relays: urls})}`
|
||||
}
|
||||
|
||||
export const getRoomItemPath = (url: string, event: TrustedEvent) => {
|
||||
switch (event.kind) {
|
||||
case THREAD:
|
||||
|
||||
@@ -37,8 +37,6 @@ import {
|
||||
loadFollowList,
|
||||
loadMuteList,
|
||||
loadProfile,
|
||||
userFollowList,
|
||||
getFollows,
|
||||
repository,
|
||||
shouldUnwrap,
|
||||
hasNegentropy,
|
||||
@@ -250,17 +248,6 @@ const syncUserData = () => {
|
||||
loadFeedsForPubkey(pubkey)
|
||||
}
|
||||
|
||||
const syncFollowNetwork = ($userFollowList: List | undefined) => {
|
||||
const author = $userFollowList?.event?.pubkey
|
||||
|
||||
if (!author) return
|
||||
|
||||
for (const follow of getFollows(author)) {
|
||||
loadFollowList(follow)
|
||||
loadMuteList(follow)
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribeGroupList = merged([userGroupList]).subscribe(([$userGroupList]) => {
|
||||
syncGroupList($userGroupList)
|
||||
})
|
||||
@@ -269,13 +256,10 @@ const syncUserData = () => {
|
||||
syncRelayList($userRelayList)
|
||||
})
|
||||
|
||||
const unsubscribeFollowList = userFollowList.subscribe(syncFollowNetwork)
|
||||
|
||||
return () => {
|
||||
unsubscribersByKey.forEach(call)
|
||||
unsubscribeGroupList()
|
||||
unsubscribeRelayList()
|
||||
unsubscribeFollowList()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-16
@@ -1,29 +1,40 @@
|
||||
import twColors from "tailwindcss/colors"
|
||||
import {hash} from "@welshman/lib"
|
||||
import {kv} from "@app/storage"
|
||||
import {synced} from "@welshman/store"
|
||||
|
||||
// Per-identity color palette, biased toward warm, saturated, brand-adjacent
|
||||
// hues so each person/space is recognizable at a glance. Deduped (was two
|
||||
// `sky` entries) and trimmed of low-distinctness gray (`zinc`). The 600 weight
|
||||
// reads clearly on both the warm-paper light base and the warm-charcoal dark
|
||||
// base. Each entry is [name, hex].
|
||||
export const colors = [
|
||||
["amber", twColors.amber[600]],
|
||||
["blue", twColors.blue[600]],
|
||||
["cyan", twColors.cyan[600]],
|
||||
["emerald", twColors.emerald[600]],
|
||||
["fuchsia", twColors.fuchsia[600]],
|
||||
["green", twColors.green[600]],
|
||||
["indigo", twColors.indigo[600]],
|
||||
["sky", twColors.sky[600]],
|
||||
["lime", twColors.lime[600]],
|
||||
["orange", twColors.orange[600]],
|
||||
["pink", twColors.pink[600]],
|
||||
["purple", twColors.purple[600]],
|
||||
["red", twColors.red[600]],
|
||||
["rose", twColors.rose[600]],
|
||||
["sky", twColors.sky[600]],
|
||||
["teal", twColors.teal[600]],
|
||||
["violet", twColors.violet[600]],
|
||||
["indigo", twColors.indigo[600]],
|
||||
["fuchsia", twColors.fuchsia[600]],
|
||||
["pink", twColors.pink[600]],
|
||||
["rose", twColors.rose[600]],
|
||||
["red", twColors.red[600]],
|
||||
["orange", twColors.orange[600]],
|
||||
["amber", twColors.amber[600]],
|
||||
["yellow", twColors.yellow[600]],
|
||||
["zinc", twColors.zinc[600]],
|
||||
["lime", twColors.lime[600]],
|
||||
["green", twColors.green[600]],
|
||||
["emerald", twColors.emerald[600]],
|
||||
["teal", twColors.teal[600]],
|
||||
["cyan", twColors.cyan[600]],
|
||||
["sky", twColors.sky[600]],
|
||||
["blue", twColors.blue[600]],
|
||||
]
|
||||
|
||||
// Single source of truth for per-pubkey identity color — reused by username
|
||||
// text, avatar gradient fallbacks, and avatar rings. Deterministic per pubkey.
|
||||
export const getColor = (pubkey = "") => colors[hash(pubkey) % colors.length][1]
|
||||
|
||||
// Which blob variant (1-3) a pubkey gets, so an avatar's organic shape is stable.
|
||||
export const getBlobVariant = (pubkey = "") => (hash(pubkey) % 3) + 1
|
||||
|
||||
export const theme = synced({
|
||||
key: "theme",
|
||||
defaultValue: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
|
||||
|
||||
+1
-21
@@ -1,6 +1,5 @@
|
||||
import {append, identity, uniq} from "@welshman/lib"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {repository, displayProfileByPubkey} from "@welshman/app"
|
||||
import {repository} from "@welshman/app"
|
||||
import {displayPubkey, getTagValue} from "@welshman/util"
|
||||
import {PLATFORM_NAME} from "@app/env"
|
||||
import {decodeRelay} from "@app/relays"
|
||||
@@ -25,7 +24,6 @@ const staticTitles = new Map<string, string>([
|
||||
["/chat", "Messages"],
|
||||
["/join", "Join Space"],
|
||||
["/people", "Find People"],
|
||||
["/people/[npub]", "Profile"],
|
||||
["/settings/about", "About"],
|
||||
["/settings/profile", "Profile Settings"],
|
||||
["/settings/content", "Content Settings"],
|
||||
@@ -122,24 +120,6 @@ export const getPageTitle = ({page, pubkey}: PageTitleContext) => {
|
||||
return makeTitle(getChatTitle(page.params.chat, pubkey))
|
||||
}
|
||||
|
||||
if (routeId === "/people/[npub]") {
|
||||
try {
|
||||
const decoded = nip19.decode(page.params.npub!)
|
||||
const profilePubkey =
|
||||
decoded.type === "npub"
|
||||
? decoded.data
|
||||
: decoded.type === "nprofile"
|
||||
? decoded.data.pubkey
|
||||
: undefined
|
||||
|
||||
if (profilePubkey) {
|
||||
return makeTitle(displayProfileByPubkey(profilePubkey))
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
if (routeId === "/spaces/[relay]/[h]") {
|
||||
return makeTitle(getRoomTitle(page.params))
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"aria-pressed"?: boolean
|
||||
} = $props()
|
||||
|
||||
const className = $derived(`text-left cursor-pointer ${restProps.class}`)
|
||||
const className = $derived(
|
||||
`text-left cursor-pointer motion-safe:transition-transform motion-safe:duration-150 motion-safe:active:scale-[0.97] ${restProps.class}`,
|
||||
)
|
||||
|
||||
const onClick = (e: Event) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -11,21 +11,25 @@
|
||||
const {...props}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="btn flex h-[unset] w-full flex-nowrap py-4 text-left {props.class}">
|
||||
<div class="flex grow flex-row items-start gap-4">
|
||||
<div class="flex h-14 w-12 shrink-0 items-center justify-center">
|
||||
<div
|
||||
class="group btn rounded-box shadow-soft flex h-[unset] w-full flex-nowrap py-4 text-left transition-all motion-safe:hover:-translate-y-0.5 {props.class}">
|
||||
<div class="flex grow flex-row items-center gap-4">
|
||||
<div class="bg-base-content/5 flex size-12 shrink-0 items-center justify-center rounded-2xl">
|
||||
{@render props.icon?.()}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-bold text-lg">
|
||||
<p class="text-lg font-bold">
|
||||
{@render props.title?.()}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
<p class="text-sm font-normal opacity-70">
|
||||
{@render props.info?.()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden h-14 w-14 items-center justify-end sm:flex">
|
||||
<Icon size={7} icon={AltArrowRight} />
|
||||
<Icon
|
||||
size={7}
|
||||
icon={AltArrowRight}
|
||||
class="transition-transform motion-safe:group-hover:translate-x-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
|
||||
const innerClass = $derived(
|
||||
cx(
|
||||
"relative text-base-content text-base-content grow pointer-events-auto",
|
||||
"rounded-t-box sm:rounded-box",
|
||||
"relative text-base-content grow pointer-events-auto",
|
||||
"rounded-t-box sm:rounded-box sm:rounded-[2rem] ring-1 ring-base-content/5",
|
||||
{
|
||||
"bg-alt shadow-m max-h-[90vh] flex flex-col max-w-full pb-sai sm:pb-0": !fullscreen,
|
||||
"bg-alt shadow-2xl max-h-[90vh] flex flex-col max-w-full pb-sai sm:pb-0": !fullscreen,
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -48,7 +48,7 @@
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close dialog"
|
||||
class="absolute inset-0 cursor-pointer bg-black opacity-50 dark:opacity-75"
|
||||
class="absolute inset-0 cursor-pointer bg-[oklch(12%_0.03_285)] opacity-50 backdrop-blur-sm dark:opacity-70"
|
||||
transition:fade={{duration: 200}}
|
||||
onclick={onClose}>
|
||||
</button>
|
||||
|
||||
@@ -8,10 +8,18 @@
|
||||
const {children, ...props}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2 p-2 text-xs uppercase opacity-50">
|
||||
<div class="h-px grow bg-base-content opacity-25"></div>
|
||||
<div class="flex items-center gap-3 p-2 text-xs">
|
||||
{#if children}
|
||||
<p>{@render children?.()}</p>
|
||||
<div class="h-px grow bg-base-content opacity-25"></div>
|
||||
<div class="via-base-content/20 h-px grow bg-gradient-to-r from-transparent to-transparent">
|
||||
</div>
|
||||
<p
|
||||
class="font-display bg-base-100 shadow-soft rounded-full px-3 py-1 font-semibold tracking-wide uppercase opacity-80">
|
||||
{@render children?.()}
|
||||
</p>
|
||||
<div class="via-base-content/20 h-px grow bg-gradient-to-r from-transparent to-transparent">
|
||||
</div>
|
||||
{:else}
|
||||
<div class="via-base-content/20 h-px grow bg-gradient-to-r from-transparent to-transparent">
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<div class={cx("fixed bottom-20 mb-sai 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"
|
||||
class="btn btn-primary size-14 rounded-full border-none p-0 shadow-[0_8px_24px_-6px_var(--color-primary)] transition-transform motion-safe:hover:scale-110 motion-safe:active:scale-95"
|
||||
{onclick}>
|
||||
<div class="flex items-center justify-center">
|
||||
{@render children?.()}
|
||||
|
||||
@@ -6,9 +6,16 @@
|
||||
alt: string
|
||||
size?: number
|
||||
class?: string
|
||||
style?: string
|
||||
}
|
||||
|
||||
const {src, alt, size = 5, ...props}: Props = $props()
|
||||
const {src, alt, size = 5, style = "", ...props}: Props = $props()
|
||||
|
||||
// Tailwind can't compile interpolated `h-{size}` classes, so size the box
|
||||
// with an inline style (size * 4 == the Tailwind `h-{size}` rem scale).
|
||||
const px = size * 4
|
||||
|
||||
let loaded = $state(false)
|
||||
</script>
|
||||
|
||||
{#if src.includes("image/svg") || src.endsWith(".svg")}
|
||||
@@ -17,5 +24,10 @@
|
||||
<img
|
||||
{src}
|
||||
{alt}
|
||||
class="h-{size} w-{size} min-w-{size} min-h-{size} aspect-square object-cover {props.class}" />
|
||||
style="width:{px}px;height:{px}px;min-width:{px}px;min-height:{px}px;{style}"
|
||||
class="aspect-square object-cover motion-safe:transition-opacity motion-safe:duration-300 {loaded
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'} {props.class}"
|
||||
onload={() => (loaded = true)}
|
||||
onerror={() => (loaded = true)} />
|
||||
{/if}
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
|
||||
<div
|
||||
data-component="Page"
|
||||
class="relative 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}">
|
||||
class="relative grow flex flex-col min-w-0 ml-sai mb-sai mt-sai mr-sai bg-base-200 md:ml-0 md:mb-0 md:rounded-tl-2xl {props.class}">
|
||||
{@render props.children?.()}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
</script>
|
||||
|
||||
<div class="column gap-4 py-12">
|
||||
<h1 class="superheading">{@render title?.()}</h1>
|
||||
<h1 class="font-display text-center text-4xl leading-tight font-bold tracking-tight">
|
||||
{@render title?.()}
|
||||
</h1>
|
||||
<p class="text-center">{@render info?.()}</p>
|
||||
</div>
|
||||
|
||||
@@ -16,33 +16,46 @@
|
||||
const active = $derived($page.url?.pathname?.startsWith(prefix || href || "bogus"))
|
||||
|
||||
const wrapperClass = $derived(
|
||||
cx("relative h-14 w-14 p-1", {
|
||||
cx("group 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",
|
||||
"relative flex h-full w-full cursor-pointer items-center justify-center transition-all duration-200 hover:bg-base-300 motion-safe:hover:scale-105 motion-safe:hover:-rotate-3",
|
||||
restProps.class,
|
||||
{"bg-base-300 border border-solid border-base-content/20": active},
|
||||
active
|
||||
? "rounded-[42%] bg-base-300 ring-2 ring-primary/60 shadow-[0_0_14px_-3px_var(--color-primary)]"
|
||||
: "rounded-2xl",
|
||||
),
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class={wrapperClass} data-tip={title}>
|
||||
<!-- Discord-style accent pill: tall when active, a nub on hover -->
|
||||
<div
|
||||
class={cx(
|
||||
"pointer-events-none absolute top-1/2 left-0 w-1 -translate-y-1/2 rounded-r-full bg-primary transition-all duration-200",
|
||||
active ? "h-8 opacity-100" : "h-2 opacity-0 group-hover:h-4 group-hover:opacity-60",
|
||||
)}>
|
||||
</div>
|
||||
{#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>
|
||||
<div
|
||||
class="bg-secondary ring-base-200 absolute top-1 right-1 h-2.5 w-2.5 rounded-full ring-2 motion-safe:animate-pulse">
|
||||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
<div
|
||||
class="bg-secondary ring-base-200 absolute top-1 right-1 h-2.5 w-2.5 rounded-full ring-2 motion-safe:animate-pulse">
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<div
|
||||
class={cx(
|
||||
"mt-sai mb-sai max-h-screen w-60 min-h-0 shrink-0 flex-col gap-1 bg-base-300 z-nav",
|
||||
"mt-sai mb-sai max-h-screen w-60 min-h-0 shrink-0 flex-col gap-1 bg-base-300 z-nav md:shadow-[6px_0_24px_-12px_rgba(0,0,0,0.35)]",
|
||||
visible ? "flex" : "hidden md:flex",
|
||||
props.class,
|
||||
)}>
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
const {children}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between px-1 py-2 text-sm font-bold uppercase">
|
||||
<div class="label flex items-center justify-between px-1 py-2">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -36,11 +36,15 @@
|
||||
|
||||
const active = $derived($page.url.pathname === href)
|
||||
const wrapperClass = $derived(
|
||||
cx(restProps.class, "relative 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,
|
||||
}),
|
||||
cx(
|
||||
restProps.class,
|
||||
"group relative flex shrink-0 items-center gap-3 rounded-xl text-left transition-all",
|
||||
{
|
||||
"hover:bg-base-100": true,
|
||||
"bg-primary/15 text-primary font-semibold": active,
|
||||
"tooltip tooltip-right": title,
|
||||
},
|
||||
),
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -51,16 +55,19 @@
|
||||
data-tip={title}
|
||||
data-sveltekit-replacestate={replaceState}
|
||||
class={wrapperClass}>
|
||||
{#if active}
|
||||
<div class="bg-primary absolute top-1/2 left-0 h-5 w-1 -translate-y-1/2 rounded-r-full"></div>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
{#if notification}
|
||||
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade>
|
||||
<div class="bg-secondary absolute top-5 right-[1.15rem] h-2 w-2 rounded-full" transition:fade>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<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 class="bg-secondary absolute top-5 right-[1.15rem] h-2 w-2 rounded-full" transition:fade>
|
||||
</div>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
<span class="flex min-h-10 items-center">
|
||||
{#if loading}
|
||||
<span class="pr-3" transition:slide|local={{axis: "x"}}>
|
||||
<span class="loading loading-spinner" transition:fade|local={{duration: 100}}></span>
|
||||
<span class="loading loading-dots text-primary" transition:fade|local={{duration: 100}}
|
||||
></span>
|
||||
</span>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
// @ts-nocheck
|
||||
import {cubicOut} from "svelte/easing"
|
||||
import {cubicOut, backOut} from "svelte/easing"
|
||||
import type {FlyParams} from "svelte/transition"
|
||||
import {fly as baseFly} from "svelte/transition"
|
||||
|
||||
export {fade, slide} from "svelte/transition"
|
||||
export {fade, slide, scale} from "svelte/transition"
|
||||
|
||||
// A short, gently-springy fly is the app's default element entrance.
|
||||
export const fly = (node: Element, params?: FlyParams | undefined) =>
|
||||
baseFly(node, {y: 20, ...params})
|
||||
baseFly(node, {y: 12, duration: 250, easing: backOut, ...params})
|
||||
|
||||
// Staggered variant for list items — pass {i} (the index) to cascade the
|
||||
// entrance. Delay is capped so long/virtualized lists never feel sluggish.
|
||||
export const flyStagger = (node: Element, {i = 0, ...params}: any = {}) =>
|
||||
baseFly(node, {y: 12, duration: 250, easing: backOut, delay: Math.min(i * 40, 240), ...params})
|
||||
|
||||
export type TranslateParams = {
|
||||
delay?: number
|
||||
|
||||
@@ -8,21 +8,13 @@
|
||||
import {page} from "$app/stores"
|
||||
import {goto} from "$app/navigation"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import {goToEvent, makeProfilePath} from "@app/routes"
|
||||
import {goToEvent} from "@app/routes"
|
||||
|
||||
const {bech32} = $page.params as MakeNonOptional<typeof $page.params>
|
||||
|
||||
const attemptToNavigate = async () => {
|
||||
const {type, data} = nip19.decode(bech32) as any
|
||||
|
||||
if (type === "npub") {
|
||||
return goto(makeProfilePath(data), {replaceState: true})
|
||||
}
|
||||
|
||||
if (type === "nprofile") {
|
||||
return goto(makeProfilePath(data.pubkey), {replaceState: true})
|
||||
}
|
||||
|
||||
if (!["nevent", "naddr"].includes(type) && data.relays.length > 0) {
|
||||
return goto("/", {replaceState: true})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import CardButton from "@lib/components/CardButton.svelte"
|
||||
import {goToSpace} from "@app/routes"
|
||||
import {PLATFORM_NAME, PLATFORM_RELAYS} from "@app/env"
|
||||
import {PLATFORM_NAME, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/env"
|
||||
|
||||
const openChat = () => goto("/chat")
|
||||
|
||||
@@ -19,16 +19,28 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="hero min-h-screen overflow-auto pb-8">
|
||||
<div class="hero-content">
|
||||
<div class="hero relative min-h-screen overflow-auto pb-8">
|
||||
<!-- Soft brand-color blobs for a warm, playful backdrop -->
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div class="bg-primary/10 absolute top-10 -left-20 size-72 rounded-full blur-3xl"></div>
|
||||
<div class="bg-secondary/10 absolute right-[-4rem] bottom-10 size-72 rounded-full blur-3xl">
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-content relative">
|
||||
<div class="column content gap-4">
|
||||
<h1 class="text-center text-5xl">Welcome to</h1>
|
||||
<h1 class="mb-4 text-center text-5xl font-bold uppercase">{PLATFORM_NAME}</h1>
|
||||
<div class="mb-2 flex flex-col items-center gap-3">
|
||||
<img
|
||||
src={PLATFORM_LOGO}
|
||||
alt={PLATFORM_NAME}
|
||||
class="shadow-soft ring-primary/20 size-20 rounded-3xl object-cover ring-4 motion-safe:animate-float" />
|
||||
<h1 class="font-display text-2xl font-semibold opacity-60">Welcome to</h1>
|
||||
<h1 class="brand text-6xl">{PLATFORM_NAME}</h1>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<Link href="/spaces">
|
||||
<CardButton class="btn-neutral">
|
||||
{#snippet icon()}
|
||||
<Icon icon={AddCircle} size={7} />
|
||||
<Icon icon={AddCircle} size={7} class="text-primary" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Add a space</div>
|
||||
@@ -41,7 +53,7 @@
|
||||
<Button onclick={openChat}>
|
||||
<CardButton class="btn-neutral">
|
||||
{#snippet icon()}
|
||||
<Icon icon={ChatRound} size={7} />
|
||||
<Icon icon={ChatRound} size={7} class="text-secondary" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Start a conversation</div>
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {page} from "$app/stores"
|
||||
import {goto} from "$app/navigation"
|
||||
import type {MakeNonOptional} from "@welshman/lib"
|
||||
import {
|
||||
loadProfile,
|
||||
loadRelayList,
|
||||
loadFollowList,
|
||||
loadMessagingRelayList,
|
||||
loadPinList,
|
||||
} from "@welshman/app"
|
||||
import {load} from "@welshman/net"
|
||||
import {Router} from "@welshman/router"
|
||||
import {ROOMS, NOTE} from "@welshman/util"
|
||||
import Page from "@lib/components/Page.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import ProfilePage from "@app/components/ProfilePage.svelte"
|
||||
import {loadGroupList} from "@app/groups"
|
||||
|
||||
const {npub} = $page.params as MakeNonOptional<typeof $page.params>
|
||||
|
||||
let pubkey = $state<string | undefined>()
|
||||
let ready = $state(false)
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const decoded = nip19.decode(npub)
|
||||
|
||||
if (decoded.type === "npub") {
|
||||
pubkey = decoded.data
|
||||
} else if (decoded.type === "nprofile") {
|
||||
pubkey = decoded.data.pubkey
|
||||
} else {
|
||||
goto("/people", {replaceState: true})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await loadProfile(pubkey)
|
||||
await loadRelayList(pubkey)
|
||||
|
||||
await Promise.all([
|
||||
loadFollowList(pubkey),
|
||||
loadPinList(pubkey),
|
||||
loadGroupList(pubkey),
|
||||
loadMessagingRelayList(pubkey),
|
||||
])
|
||||
|
||||
load({
|
||||
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
|
||||
filters: [
|
||||
{authors: [pubkey], kinds: [ROOMS]},
|
||||
{authors: [pubkey], kinds: [NOTE], limit: 1},
|
||||
],
|
||||
})
|
||||
|
||||
ready = true
|
||||
} catch {
|
||||
goto("/people", {replaceState: true})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Page>
|
||||
<PageContent class="p-0 md:p-4">
|
||||
{#if ready && pubkey}
|
||||
<ProfilePage {pubkey} />
|
||||
{:else}
|
||||
<p class="center flex py-20">
|
||||
<Spinner loading />
|
||||
</p>
|
||||
{/if}
|
||||
</PageContent>
|
||||
</Page>
|
||||
@@ -1,15 +1,11 @@
|
||||
<script lang="ts">
|
||||
import {session} from "@welshman/app"
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Zap from "@app/components/Zap.svelte"
|
||||
import ZapInvoice from "@app/components/ZapInvoice.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import {PLATFORM_NAME} from "@app/env"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
import Code from "@assets/icons/code-2.svg?dataurl"
|
||||
import Global from "@assets/icons/global.svg?dataurl"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
@@ -20,8 +16,6 @@
|
||||
const pubkey = "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey})
|
||||
|
||||
const zap = () => pushModal($session?.wallet ? Zap : ZapInvoice, {pubkey})
|
||||
</script>
|
||||
|
||||
<div class="mt-8 min-h-screen bg-base-200 sm:hero">
|
||||
@@ -34,14 +28,18 @@
|
||||
<div class="card2 bg-alt flex flex-col gap-2 text-center shadow-lg">
|
||||
<h3 class="text-2xl sm:h-12">Donate</h3>
|
||||
<p class="sm:h-16">Funds will be used to support development.</p>
|
||||
<Button onclick={zap} class="btn btn-primary">Zap the Developer</Button>
|
||||
<Link external href="https://geyser.fund/project/flotilla" class="btn btn-primary">
|
||||
Support the Developer
|
||||
</Link>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="card2 bg-alt flex flex-col gap-2 text-center shadow-lg">
|
||||
<h3 class="text-2xl sm:h-12">Get in touch</h3>
|
||||
<p class="sm:h-16">Having problems? Let us know.</p>
|
||||
<Link class="btn btn-primary" href={makeSpacePath("support.flotilla.social")}>
|
||||
Get Support
|
||||
<Link
|
||||
class="btn btn-primary"
|
||||
href="/chat/97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322">
|
||||
Chat with the Developer
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -303,7 +303,9 @@
|
||||
</div>
|
||||
{/each}
|
||||
{:else if !term}
|
||||
<p class="py-12 text-center">You haven't joined any spaces yet.</p>
|
||||
<p class="py-10 text-center opacity-70">
|
||||
You haven't joined any spaces yet — pick one below to dive in!
|
||||
</p>
|
||||
{/if}
|
||||
<Divider>{filteredUserUrls.length > 0 ? "More Spaces" : "Browse Spaces"}</Divider>
|
||||
{#each otherSpaces.slice(0, limit) as relay (relay.url)}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
|
||||
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
|
||||
import EmptyState from "@app/components/EmptyState.svelte"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {decodeRelay} from "@app/relays"
|
||||
import {makeCommentFilter} from "@app/content"
|
||||
@@ -147,7 +148,9 @@
|
||||
<Spinner {loading}>Looking for events...</Spinner>
|
||||
</p>
|
||||
{:else if items.length === 0}
|
||||
<p class="flex h-10 items-center justify-center py-20" transition:fly>No events found.</p>
|
||||
<EmptyState icon={CalendarMinimalistic} title="No events yet">
|
||||
Planning a meetup or call? Add an event so everyone knows when to show up.
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<p class="flex h-10 items-center justify-center py-20" transition:fly>That's all!</p>
|
||||
{/if}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||
import GoalItem from "@app/components/GoalItem.svelte"
|
||||
import GoalCreate from "@app/components/GoalCreate.svelte"
|
||||
import EmptyState from "@app/components/EmptyState.svelte"
|
||||
import {decodeRelay} from "@app/relays"
|
||||
import {makeCommentFilter} from "@app/content"
|
||||
import {makeFeed} from "@app/feeds"
|
||||
@@ -83,15 +84,15 @@
|
||||
<GoalItem {url} event={$state.snapshot(event)} />
|
||||
</div>
|
||||
{/each}
|
||||
<p class="flex h-10 items-center justify-center py-20">
|
||||
<Spinner {loading}>
|
||||
{#if loading}
|
||||
Looking for goals...
|
||||
{:else if items.length === 0}
|
||||
No goals found.
|
||||
{:else}
|
||||
That's all!
|
||||
{/if}
|
||||
</Spinner>
|
||||
</p>
|
||||
{#if loading}
|
||||
<p class="flex h-10 items-center justify-center py-20">
|
||||
<Spinner loading>Looking for goals...</Spinner>
|
||||
</p>
|
||||
{:else if items.length === 0}
|
||||
<EmptyState icon={StarFallMinimalistic} title="No goals yet">
|
||||
Rallying the community around something? Set a goal and watch the support roll in.
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<p class="flex items-center justify-center py-10 text-sm opacity-50">That's all!</p>
|
||||
{/if}
|
||||
</PageContent>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||
import PollItem from "@app/components/PollItem.svelte"
|
||||
import PollCreate from "@app/components/PollCreate.svelte"
|
||||
import EmptyState from "@app/components/EmptyState.svelte"
|
||||
import {decodeRelay} from "@app/relays"
|
||||
import {makeCommentFilter} from "@app/content"
|
||||
import {makeFeed} from "@app/feeds"
|
||||
@@ -83,15 +84,15 @@
|
||||
<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>
|
||||
{#if loading}
|
||||
<p class="flex h-10 items-center justify-center py-20">
|
||||
<Spinner loading>Looking for polls...</Spinner>
|
||||
</p>
|
||||
{:else if items.length === 0}
|
||||
<EmptyState icon={PollIcon} title="No polls yet">
|
||||
Want to take the room's temperature? Create the first poll and let people weigh in.
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<p class="flex items-center justify-center py-10 text-sm opacity-50">That's all!</p>
|
||||
{/if}
|
||||
</PageContent>
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import {readable} from "svelte/store"
|
||||
import type {Readable} from "svelte/store"
|
||||
import {page} from "$app/stores"
|
||||
import {sortBy, partition, spec, max, pushToMapKey, groupBy} from "@welshman/lib"
|
||||
import {sortBy, partition, spec, max, pushToMapKey} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {THREAD, getTagValue} from "@welshman/util"
|
||||
import {fly} from "@lib/transition"
|
||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||
import Add from "@assets/icons/add.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
@@ -13,10 +14,10 @@
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||
import ThreadBoard from "@app/components/ThreadBoard.svelte"
|
||||
import ThreadItem from "@app/components/ThreadItem.svelte"
|
||||
import ThreadCreate from "@app/components/ThreadCreate.svelte"
|
||||
import EmptyState from "@app/components/EmptyState.svelte"
|
||||
import {decodeRelay} from "@app/relays"
|
||||
import {displayRoom} from "@app/groups"
|
||||
import {makeCommentFilter} from "@app/content"
|
||||
import {makeFeed} from "@app/feeds"
|
||||
import {pushModal} from "@app/modal"
|
||||
@@ -29,9 +30,9 @@
|
||||
|
||||
const createThread = () => pushModal(ThreadCreate, {url})
|
||||
|
||||
const threadFeed = $derived.by(() => {
|
||||
const items = $derived.by(() => {
|
||||
const scores = new Map<string, number[]>()
|
||||
const [threads, comments] = partition(spec({kind: THREAD}), $events)
|
||||
const [goals, comments] = partition(spec({kind: THREAD}), $events)
|
||||
|
||||
for (const comment of comments) {
|
||||
const id = getTagValue("E", comment.tags)
|
||||
@@ -41,13 +42,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const items = sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), threads)
|
||||
|
||||
const byRoom = groupBy(e => getTagValue("h", e.tags) || "", items)
|
||||
const roomName = (h: string) => (h ? displayRoom(url, h) : "general").toLowerCase()
|
||||
const boards = sortBy(([h]) => roomName(h), Array.from(byRoom.entries()))
|
||||
|
||||
return {items, boards}
|
||||
return sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), goals)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
@@ -83,19 +78,21 @@
|
||||
{/snippet}
|
||||
</SpaceBar>
|
||||
|
||||
<PageContent bind:element class="flex flex-col gap-4 p-2">
|
||||
{#each threadFeed.boards as [h, threads] (h || "general")}
|
||||
<ThreadBoard {url} {h} {threads} />
|
||||
<PageContent bind:element class="flex flex-col gap-2 p-2">
|
||||
{#each items as event (event.id)}
|
||||
<div in:fly>
|
||||
<ThreadItem {url} event={$state.snapshot(event)} />
|
||||
</div>
|
||||
{/each}
|
||||
<p class="flex h-10 items-center justify-center py-20">
|
||||
<Spinner {loading}>
|
||||
{#if loading}
|
||||
Looking for threads...
|
||||
{:else if threadFeed.items.length === 0}
|
||||
No threads found.
|
||||
{:else}
|
||||
That's all!
|
||||
{/if}
|
||||
</Spinner>
|
||||
</p>
|
||||
{#if loading}
|
||||
<p class="flex h-10 items-center justify-center py-20">
|
||||
<Spinner loading>Looking for threads...</Spinner>
|
||||
</p>
|
||||
{:else if items.length === 0}
|
||||
<EmptyState icon={NotesMinimalistic} title="No threads yet">
|
||||
Threads keep longer conversations tidy and easy to follow. Be the first to start one!
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<p class="flex items-center justify-center py-10 text-sm opacity-50">That's all!</p>
|
||||
{/if}
|
||||
</PageContent>
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {page} from "$app/stores"
|
||||
import {goto} from "$app/navigation"
|
||||
import {sleep} from "@welshman/lib"
|
||||
import type {MakeNonOptional} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {COMMENT, getTagValue} 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 Reply from "@assets/icons/reply-2.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 Link from "@lib/components/Link.svelte"
|
||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||
import ThreadPost from "@app/components/ThreadPost.svelte"
|
||||
import ThreadPagination from "@app/components/ThreadPagination.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import NoteCard from "@app/components/NoteCard.svelte"
|
||||
import ThreadActions from "@app/components/ThreadActions.svelte"
|
||||
import CommentActions from "@app/components/CommentActions.svelte"
|
||||
import EventReply from "@app/components/EventReply.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import {deriveEvent} from "@app/repository"
|
||||
import {decodeRelay} from "@app/relays"
|
||||
import {makeSpacePath, scrollToEvent} from "@app/routes"
|
||||
|
||||
const POSTS_PER_PAGE = 20
|
||||
|
||||
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
|
||||
const url = decodeRelay(relay)
|
||||
@@ -35,106 +30,20 @@
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const posts = $derived.by(() => {
|
||||
if (!$event) return []
|
||||
|
||||
return [$event, ...$replies]
|
||||
})
|
||||
|
||||
const replyCount = $derived(Math.max(0, posts.length - 1))
|
||||
const h = $derived(getTagValue("h", $event?.tags || []))
|
||||
|
||||
const pageCount = $derived(Math.max(1, Math.ceil(posts.length / POSTS_PER_PAGE)))
|
||||
|
||||
const currentPage = $derived.by(() => {
|
||||
const raw = parseInt($page.url.searchParams.get("page") || "1")
|
||||
|
||||
if (Number.isNaN(raw) || raw < 1) return 1
|
||||
if (raw > pageCount) return pageCount
|
||||
|
||||
return raw
|
||||
})
|
||||
|
||||
const pagePosts = $derived(
|
||||
posts.slice((currentPage - 1) * POSTS_PER_PAGE, currentPage * POSTS_PER_PAGE),
|
||||
)
|
||||
|
||||
const setPage = (nextPage: number) => {
|
||||
const params = new URLSearchParams($page.url.searchParams)
|
||||
|
||||
if (nextPage <= 1) {
|
||||
params.delete("page")
|
||||
} else {
|
||||
params.set("page", String(nextPage))
|
||||
}
|
||||
|
||||
const search = params.toString()
|
||||
|
||||
goto(`${$page.url.pathname}${search ? `?${search}` : ""}`, {
|
||||
keepFocus: true,
|
||||
noScroll: true,
|
||||
})
|
||||
}
|
||||
|
||||
const openReply = (post: TrustedEvent) => {
|
||||
replyTo = post
|
||||
const openReply = () => {
|
||||
showReply = true
|
||||
}
|
||||
|
||||
const closeReply = () => {
|
||||
showReply = false
|
||||
replyTo = undefined
|
||||
}
|
||||
|
||||
const openThreadReply = () => {
|
||||
if ($event) {
|
||||
openReply($event)
|
||||
}
|
||||
}
|
||||
|
||||
const clearReplyParent = () => {
|
||||
if ($event) {
|
||||
replyTo = $event
|
||||
}
|
||||
const expand = () => {
|
||||
showAll = true
|
||||
}
|
||||
|
||||
let showAll = $state(false)
|
||||
let showReply = $state(false)
|
||||
let replyTo: TrustedEvent | undefined = $state()
|
||||
let hashHandled = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (hashHandled || posts.length === 0) return
|
||||
|
||||
const hash = window.location.hash.replace(/^#/, "")
|
||||
|
||||
if (!hash.startsWith("nevent1")) return
|
||||
|
||||
let eventId: string
|
||||
|
||||
try {
|
||||
const decoded = nip19.decode(hash)
|
||||
|
||||
if (decoded.type !== "nevent") return
|
||||
|
||||
eventId = decoded.data.id
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const index = posts.findIndex(post => post.id === eventId)
|
||||
|
||||
if (index < 0) return
|
||||
|
||||
hashHandled = true
|
||||
|
||||
const targetPage = Math.ceil((index + 1) / POSTS_PER_PAGE)
|
||||
|
||||
if (targetPage !== currentPage) {
|
||||
setPage(targetPage)
|
||||
}
|
||||
|
||||
setTimeout(() => scrollToEvent(posts[index]!.id), 100)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const controller = new AbortController()
|
||||
@@ -147,44 +56,43 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<SpaceBar {back} class="!h-auto min-h-20 py-3">
|
||||
<SpaceBar {back}>
|
||||
{#snippet title()}
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<h1 class="ellipsize text-base leading-none font-bold sm:text-xl">
|
||||
{getTagValue("title", $event?.tags || []) || ""}
|
||||
</h1>
|
||||
<p class="text-xs opacity-75">
|
||||
{replyCount}
|
||||
{replyCount === 1 ? "reply" : "replies"}
|
||||
{#if h}
|
||||
· <Link href={makeSpacePath(url, h)} class="link">#<RoomName {url} {h} /></Link>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<h1 class="text-xl">{getTagValue("title", $event?.tags || []) || ""}</h1>
|
||||
{/snippet}
|
||||
</SpaceBar>
|
||||
|
||||
<PageContent class="flex flex-col">
|
||||
<PageContent class="flex flex-col gap-2 p-2">
|
||||
{#if $event}
|
||||
<div class="border-y border-base-content/15 bg-base-100">
|
||||
{#each pagePosts as post (post.id)}
|
||||
<ThreadPost {url} event={post} threadPubkey={$event.pubkey} onReply={openReply} />
|
||||
<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">
|
||||
<NoteContent showEntire event={$event} {url} />
|
||||
<ThreadActions showRoom event={$event} {url} />
|
||||
</div>
|
||||
</NoteCard>
|
||||
{#if !showAll && $replies.length > 4}
|
||||
<div class="flex justify-center">
|
||||
<Button class="btn btn-link" onclick={expand}>
|
||||
<Icon icon={SortVertical} />
|
||||
Show all {$replies.length} replies
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#each $replies.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="threads" event={reply} {url} />
|
||||
</div>
|
||||
</NoteCard>
|
||||
{/each}
|
||||
</div>
|
||||
{#if pageCount > 1}
|
||||
<ThreadPagination page={currentPage} {pageCount} onPage={setPage} />
|
||||
{/if}
|
||||
{#if showReply && replyTo && $event}
|
||||
<EventReply
|
||||
{url}
|
||||
event={$event}
|
||||
parent={replyTo.id === $event.id ? undefined : replyTo}
|
||||
onClose={closeReply}
|
||||
onClearParent={clearReplyParent}
|
||||
onSubmit={closeReply} />
|
||||
{#if showReply}
|
||||
<EventReply {url} event={$event} onClose={closeReply} onSubmit={closeReply} />
|
||||
{:else}
|
||||
<div class="flex justify-end p-4">
|
||||
<Button class="btn btn-primary" onclick={openThreadReply}>
|
||||
<div class="flex justify-end">
|
||||
<Button class="btn btn-primary" onclick={openReply}>
|
||||
<Icon icon={Reply} />
|
||||
Reply to thread
|
||||
</Button>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+32
-2
@@ -33,22 +33,52 @@ export default {
|
||||
daisyTheme({
|
||||
name: "dark",
|
||||
...themes["night"],
|
||||
"--color-base-content": "oklch(75% 0.029 256.847)",
|
||||
// Warm charcoal ramp with a wide (~8.5%) lightness spread so stacked
|
||||
// surfaces (rail / panel / page / card / bubble) read as layered paper
|
||||
// instead of one flat slab. Hue nudged from cold 265 toward brand 280.
|
||||
"--color-base-100": "oklch(24% 0.025 280)",
|
||||
"--color-base-200": "oklch(20% 0.024 280)",
|
||||
"--color-base-300": "oklch(15.5% 0.022 280)",
|
||||
"--color-base-content": "oklch(89% 0.02 280)",
|
||||
"--color-primary": process.env.VITE_PLATFORM_ACCENT,
|
||||
"--color-primary-content": process.env.VITE_PLATFORM_ACCENT_CONTENT || "#EAE7FF",
|
||||
"--color-secondary": process.env.VITE_PLATFORM_SECONDARY,
|
||||
"--color-secondary-content": process.env.VITE_PLATFORM_SECONDARY_CONTENT || "#EAE7FF",
|
||||
// Amber completes the purple -> orange -> amber brand triad (was off-brand pink).
|
||||
"--color-accent": "#FDB833",
|
||||
"--color-accent-content": "oklch(22% 0.04 70)",
|
||||
// Shape + depth: rounder geometry, friendlier borders, and DaisyUI's built-in
|
||||
// soft top-highlight/bottom-shadow on btn/card/input/badge.
|
||||
"--depth": "1",
|
||||
"--radius-box": "1.25rem",
|
||||
"--radius-field": "0.75rem",
|
||||
"--radius-selector": "1rem",
|
||||
"--border": "1.5px",
|
||||
}),
|
||||
daisyTheme({
|
||||
name: "light",
|
||||
...themes["winter"],
|
||||
"--color-neutral": "#F2F7FF",
|
||||
// Warm paper ramp (hue ~70) replaces winter's clinical blue-white, with a
|
||||
// gentle layering step and a warm near-ink text color carrying a faint
|
||||
// purple undertone so type feels tied to the brand.
|
||||
"--color-base-100": "oklch(99.5% 0.006 70)",
|
||||
"--color-base-200": "oklch(97% 0.012 70)",
|
||||
"--color-base-300": "oklch(93.5% 0.016 75)",
|
||||
"--color-base-content": "oklch(32% 0.03 285)",
|
||||
"--color-neutral": "oklch(96% 0.01 70)",
|
||||
"--color-neutral-content": "var(--color-base-content)",
|
||||
"--color-warning": "#FD8D0B",
|
||||
"--color-primary": process.env.VITE_PLATFORM_ACCENT,
|
||||
"--color-primary-content": process.env.VITE_PLATFORM_ACCENT_CONTENT || "#EAE7FF",
|
||||
"--color-secondary": process.env.VITE_PLATFORM_SECONDARY,
|
||||
"--color-secondary-content": process.env.VITE_PLATFORM_SECONDARY_CONTENT || "#EAE7FF",
|
||||
"--color-accent": "#FDB833",
|
||||
"--color-accent-content": "oklch(30% 0.05 70)",
|
||||
"--depth": "1",
|
||||
"--radius-box": "1.25rem",
|
||||
"--radius-field": "0.75rem",
|
||||
"--radius-selector": "1rem",
|
||||
"--border": "1.5px",
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user