Compare commits

..

14 Commits

Author SHA1 Message Date
Jon Staab 976ccdabd4 fix: include MESSAGE kind and local matches in space search 2026-04-28 14:44:16 -07:00
Jon Staab 99b26680b6 feat: rework hosting page to 2+1 architecture (#231) 2026-04-28 14:42:09 -07:00
Jon Staab c5be477855 fix: bundle emoji-picker data locally for Capacitor Android
The emoji grid wasn't rendering on Android because emoji-picker-element
defaults to fetching its data.json from jsdelivr, and CapacitorHttp's
patched fetch breaks the library's ETag-based revalidation flow. Bundle
emoji-picker-element-data via Vite's ?url import so the JSON ships as a
same-origin asset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:15:12 -07:00
deveshanim3 32c1501e9c feat: add progress bar to signup flow (#234)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-23 15:35:59 +00:00
deveshanim3 463837e7d4 fix: restore consistent input field sizing and alignment in FieldInline (#235) (#238)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-20 16:26:44 +00:00
Shreyas2004wagh d74f142cdd Fix relay auth privacy toggle (#240)
Co-authored-by: Shreyas2004wagh <shreyaswagh2004@gmail.com>
Co-committed-by: Shreyas2004wagh <shreyaswagh2004@gmail.com>
2026-04-20 16:25:52 +00:00
Jon Staab 53954aae89 classnames tweak 2026-04-17 16:06:00 -07:00
userAdityaa 24aa62a503 chore: carify Pomade login errors with actionable invalid vs network messaging (#233)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-17 23:00:03 +00:00
Jon Staab 2618bb9c63 Fix centered layout 2026-04-17 14:58:59 -07:00
Prat_09 32a31045ef fix: Improve toggle switch placement in settings screen (#208) (#232)
Co-authored-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
Co-committed-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
2026-04-17 21:58:52 +00:00
deveshanim3 56edad77a8 fix: added logic for password requirements on signup (#230)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-17 19:43:27 +00:00
priyanshu_bharti fdb604e350 Use type=email for signup/login email inputs (#225) (#228)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-17 18:55:04 +00:00
deveshanim3 3c66dfd83c fix/wrong-message-offline (#222)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-17 18:24:55 +00:00
userAdityaa 81633b0a1e fix: vertical alignment of emoji and overflow buttons in shared event action row (#219)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-17 15:22:40 +00:00
45 changed files with 596 additions and 1062 deletions
+1
View File
@@ -85,6 +85,7 @@
"date-picker-svelte": "^2.17.0", "date-picker-svelte": "^2.17.0",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"emoji-picker-element": "^1.28.1", "emoji-picker-element": "^1.28.1",
"emoji-picker-element-data": "^1.8.0",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"idb": "^8.0.3", "idb": "^8.0.3",
+8
View File
@@ -137,6 +137,9 @@ importers:
emoji-picker-element: emoji-picker-element:
specifier: ^1.28.1 specifier: ^1.28.1
version: 1.28.1 version: 1.28.1
emoji-picker-element-data:
specifier: ^1.8.0
version: 1.8.0
fuse.js: fuse.js:
specifier: ^7.1.0 specifier: ^7.1.0
version: 7.1.0 version: 7.1.0
@@ -2824,6 +2827,9 @@ packages:
resolution: {integrity: sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==} resolution: {integrity: sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==}
engines: {node: '>= 0.4.0'} engines: {node: '>= 0.4.0'}
emoji-picker-element-data@1.8.0:
resolution: {integrity: sha512-VfRuRJNEDLS1JKlNS4olaqhjX5S1nnZ+ZHG73b/dV8QeZyi0yPruTPEE72EmF6XO3k/9hj3lybMIYMOYXb/57A==}
emoji-picker-element@1.28.1: emoji-picker-element@1.28.1:
resolution: {integrity: sha512-8c64IPish2PWoV9oYCo2pvuPHwIv+uK9bO0dfpPyMupDAvaWL9ZvYhWNTAR+2sx7BhfRjciImqP6CIUgNX+DMg==} resolution: {integrity: sha512-8c64IPish2PWoV9oYCo2pvuPHwIv+uK9bO0dfpPyMupDAvaWL9ZvYhWNTAR+2sx7BhfRjciImqP6CIUgNX+DMg==}
@@ -8028,6 +8034,8 @@ snapshots:
dependencies: dependencies:
sax: 1.1.4 sax: 1.1.4
emoji-picker-element-data@1.8.0: {}
emoji-picker-element@1.28.1: {} emoji-picker-element@1.28.1: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
+3 -2
View File
@@ -42,7 +42,7 @@
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
</script> </script>
<Button class="join rounded-full"> <div class="join items-center rounded-full">
{#if ENABLE_ZAPS && !hideZap} {#if ENABLE_ZAPS && !hideZap}
<ZapButton {url} {event} class="btn join-item btn-neutral btn-xs"> <ZapButton {url} {event} class="btn join-item btn-neutral btn-xs">
<Icon icon={Bolt} size={4} /> <Icon icon={Bolt} size={4} />
@@ -52,6 +52,7 @@
<Icon icon={SmileCircle} size={4} /> <Icon icon={SmileCircle} size={4} />
</EmojiButton> </EmojiButton>
<Tippy <Tippy
class="flex"
bind:popover bind:popover
component={EventMenu} component={EventMenu}
props={{url, noun, event, customActions, onClick: hidePopover}} props={{url, noun, event, customActions, onClick: hidePopover}}
@@ -60,4 +61,4 @@
<Icon icon={MenuDots} size={4} /> <Icon icon={MenuDots} size={4} />
</Button> </Button>
</Tippy> </Tippy>
</Button> </div>
+4 -6
View File
@@ -3,7 +3,7 @@
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {COMMENT, ManagementMethod, getTagValue} from "@welshman/util" import {COMMENT, ManagementMethod} from "@welshman/util"
import {pubkey, repository, relaysByUrl, manageRelay} from "@welshman/app" import {pubkey, repository, relaysByUrl, manageRelay} from "@welshman/app"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl" import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl" import Code2 from "@assets/icons/code-2.svg?dataurl"
@@ -17,8 +17,7 @@
import Report from "@app/components/Report.svelte" import Report from "@app/components/Report.svelte"
import EventShare from "@app/components/EventShare.svelte" import EventShare from "@app/components/EventShare.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte" import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {deriveHasPermission, ROOM_PERMISSION_DELETE_EVENT} from "@app/core/roles" import {hasNip29, deriveUserIsSpaceAdmin} from "@app/core/state"
import {hasNip29} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {makeSpaceChatPath} from "@app/util/routes" import {makeSpaceChatPath} from "@app/util/routes"
@@ -34,8 +33,7 @@
const {url, noun, event, onClick, customActions}: Props = $props() const {url, noun, event, onClick, customActions}: Props = $props()
const isRoot = event.kind !== COMMENT const isRoot = event.kind !== COMMENT
const h = getTagValue("h", event.tags) const userIsAdmin = deriveUserIsSpaceAdmin(url)
const canDelete = deriveHasPermission(url, h, ROOM_PERMISSION_DELETE_EVENT)
const report = () => pushModal(Report, {url, event}) const report = () => pushModal(Report, {url, event})
@@ -109,7 +107,7 @@
Report Content Report Content
</Button> </Button>
</li> </li>
{#if $canDelete} {#if $userIsAdmin}
<li> <li>
<Button class="text-error" onclick={showAdminDelete}> <Button class="text-error" onclick={showAdminDelete}>
<Icon size={4} icon={TrashBin2} /> <Icon size={4} icon={TrashBin2} />
+7 -1
View File
@@ -15,6 +15,7 @@
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProgressBar from "@app/components/ProgressBar.svelte"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {PLATFORM_NAME} from "@app/core/state" import {PLATFORM_NAME} from "@app/core/state"
@@ -22,9 +23,11 @@
secret: string secret: string
next: () => unknown next: () => unknown
submitText?: string submitText?: string
step?: number
totalSteps?: number
} }
const {secret, next, submitText = "Continue"}: Props = $props() const {secret, next, submitText = "Continue", step, totalSteps}: Props = $props()
const back = () => history.back() const back = () => history.back()
@@ -150,6 +153,9 @@
</Button> </Button>
</div> </div>
</ModalBody> </ModalBody>
{#if step && totalSteps}
<ProgressBar current={step} total={totalSteps} />
{/if}
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
+11 -3
View File
@@ -19,6 +19,7 @@
import LogInOTP from "@app/components/LogInOTP.svelte" import LogInOTP from "@app/components/LogInOTP.svelte"
import LogInSelect from "@app/components/LogInSelect.svelte" import LogInSelect from "@app/components/LogInSelect.svelte"
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade" import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
import {getPomadeLoginFailureMessage, POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {pushModal, clearModals} from "@app/util/modal" import {pushModal, clearModals} from "@app/util/modal"
import {setChecked} from "@app/util/notifications" import {setChecked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
@@ -44,7 +45,7 @@
return pushToast({ return pushToast({
theme: "error", theme: "error",
message: "Sorry, we were unable to log you in.", message: getPomadeLoginFailureMessage(messages),
}) })
} }
@@ -64,10 +65,17 @@
pushToast({ pushToast({
theme: "error", theme: "error",
message: "Sorry, we were unable to log you in.", message: getPomadeLoginFailureMessage(res.messages),
}) })
} }
} }
} catch (error) {
console.error("Login error:", error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally { } finally {
loading = false loading = false
} }
@@ -90,7 +98,7 @@
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Letter} /> <Icon icon={Letter} />
<input bind:value={email} /> <input type="email" bind:value={email} />
</label> </label>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
+12 -2
View File
@@ -15,6 +15,7 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import LogInOTPConfirm from "@app/components/LogInOTPConfirm.svelte" import LogInOTPConfirm from "@app/components/LogInOTPConfirm.svelte"
import {POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
@@ -35,11 +36,20 @@
if (ok) { if (ok) {
pushModal(LogInOTPConfirm, {email, peersByPrefix}) pushModal(LogInOTPConfirm, {email, peersByPrefix})
} else { } else {
console.error("Pomade challenge request failed during OTP login")
pushToast({ pushToast({
theme: "error", theme: "error",
message: "Sorry, we were unable to request a login code.", message: POMADE_NETWORK_ERROR_MESSAGE,
}) })
} }
} catch (error) {
console.error(error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally { } finally {
loading = false loading = false
} }
@@ -61,7 +71,7 @@
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Letter} /> <Icon icon={Letter} />
<input bind:value={email} /> <input type="email" bind:value={email} />
</label> </label>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
+12 -4
View File
@@ -15,10 +15,11 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import StringMultiInput from "@lib/components/StringMultiInput.svelte" import StringMultiInput from "@lib/components/StringMultiInput.svelte"
import LogInSelect from "@app/components/LogInSelect.svelte" import LogInSelect from "@app/components/LogInSelect.svelte"
import {pushToast} from "@app/util/toast"
import {setChecked} from "@app/util/notifications"
import {pushModal, clearModals} from "@app/util/modal" import {pushModal, clearModals} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade" import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
import {getPomadeLoginFailureMessage, POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {pushToast} from "@app/util/toast"
type Props = { type Props = {
email: string email: string
@@ -44,7 +45,7 @@
return pushToast({ return pushToast({
theme: "error", theme: "error",
message: "Sorry, we were unable to log you in.", message: getPomadeLoginFailureMessage(messages),
}) })
} }
@@ -64,10 +65,17 @@
pushToast({ pushToast({
theme: "error", theme: "error",
message: "Sorry, we were unable to log you in.", message: getPomadeLoginFailureMessage(res.messages),
}) })
} }
} }
} catch (error) {
console.error("Login error:", error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally { } finally {
loading = false loading = false
} }
+9 -1
View File
@@ -14,6 +14,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade" import {deleteDeactivatedPomadeSessions, loginWithPomade} from "@app/util/pomade"
import {getPomadeLoginFailureMessage, POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {setChecked} from "@app/util/notifications" import {setChecked} from "@app/util/notifications"
import {clearModals} from "@app/util/modal" import {clearModals} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
@@ -46,9 +47,16 @@
pushToast({ pushToast({
theme: "error", theme: "error",
message: "Sorry, we were unable to log you in.", message: getPomadeLoginFailureMessage(res.messages),
}) })
} }
} catch (error) {
console.error("Login error:", error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally { } finally {
loading = false loading = false
} }
+6 -30
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {readable} from "svelte/store"
import {removeUndefined} from "@welshman/lib" import {removeUndefined} from "@welshman/lib"
import {ManagementMethod} from "@welshman/util" import {ManagementMethod} from "@welshman/util"
import { import {
@@ -29,14 +28,7 @@
import ProfileInfo from "@app/components/ProfileInfo.svelte" import ProfileInfo from "@app/components/ProfileInfo.svelte"
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte" import ProfileBadges from "@app/components/ProfileBadges.svelte"
import RoleBadge from "@app/components/RoleBadge.svelte" import {pubkeyLink, deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems} from "@app/core/state"
import {pubkeyLink, deriveSpaceBannedPubkeyItems} from "@app/core/state"
import {
deriveUserHasSpacePermission,
deriveSpaceMemberRoles,
ROOM_PERMISSION_ADD_MEMBER,
ROOM_PERMISSION_BAN_USER,
} from "@app/core/roles"
import {addSpaceMembers} from "@app/core/commands" import {addSpaceMembers} from "@app/core/commands"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
@@ -51,16 +43,10 @@
const profile = deriveProfile(pubkey, removeUndefined([url])) const profile = deriveProfile(pubkey, removeUndefined([url]))
const canBan = url ? deriveUserHasSpacePermission(url, ROOM_PERMISSION_BAN_USER) : readable(false) const userIsAdmin = deriveUserIsSpaceAdmin(url)
const canRestore = url
? deriveUserHasSpacePermission(url, ROOM_PERMISSION_ADD_MEMBER)
: readable(false)
const bannedPubkeys = url ? deriveSpaceBannedPubkeyItems(url) : undefined const bannedPubkeys = url ? deriveSpaceBannedPubkeyItems(url) : undefined
const assignedRoles = url ? deriveSpaceMemberRoles(url, pubkey) : readable([])
const isBanned = $derived($bannedPubkeys?.some(item => item.pubkey === pubkey) ?? false) const isBanned = $derived($bannedPubkeys?.some(item => item.pubkey === pubkey) ?? false)
const back = () => history.back() const back = () => history.back()
@@ -119,7 +105,7 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex justify-between"> <div class="flex justify-between">
<Profile showPubkey avatarSize={14} {pubkey} {url} /> <Profile showPubkey avatarSize={14} {pubkey} {url} />
{#if $profile || $canBan || $canRestore} {#if $profile || $userIsAdmin}
<div class="relative"> <div class="relative">
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}> <Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} /> <Icon icon={MenuDots} />
@@ -137,16 +123,15 @@
</Button> </Button>
</li> </li>
{/if} {/if}
{#if $userIsAdmin}
{#if isBanned} {#if isBanned}
{#if $canRestore}
<li> <li>
<Button onclick={restoreMember}> <Button onclick={restoreMember}>
<Icon icon={Restart} /> <Icon icon={Restart} />
Restore User Restore User
</Button> </Button>
</li> </li>
{/if} {:else}
{:else if $canBan}
<li> <li>
<Button class="text-error" onclick={banMember}> <Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} /> <Icon icon={MinusCircle} />
@@ -154,6 +139,7 @@
</Button> </Button>
</li> </li>
{/if} {/if}
{/if}
</ul> </ul>
</Popover> </Popover>
{/if} {/if}
@@ -161,16 +147,6 @@
{/if} {/if}
</div> </div>
<ProfileInfo {pubkey} {url} /> <ProfileInfo {pubkey} {url} />
{#if $assignedRoles.length > 0}
<div class="card2 card2-sm bg-alt col-3">
<h3 class="text-lg font-semibold">Roles</h3>
<div class="flex flex-wrap gap-2">
{#each $assignedRoles as role (role.name)}
<RoleBadge {role} class="badge-md" />
{/each}
</div>
</div>
{/if}
<ProfileBadges {pubkey} {url} /> <ProfileBadges {pubkey} {url} />
</div> </div>
</ModalBody> </ModalBody>
+5 -1
View File
@@ -23,9 +23,10 @@
onsubmit: (values: Values) => void onsubmit: (values: Values) => void
isSignup?: boolean isSignup?: boolean
footer: Snippet footer: Snippet
progressBar?: Snippet
} }
const {initialValues, isSignup, onsubmit, footer}: Props = $props() const {initialValues, isSignup, onsubmit, footer, progressBar}: Props = $props()
const values = $state(initialValues) const values = $state(initialValues)
@@ -103,6 +104,9 @@
</Field> </Field>
{/if} {/if}
</ModalBody> </ModalBody>
{#if progressBar}
{@render progressBar()}
{/if}
<ModalFooter> <ModalFooter>
{@render footer()} {@render footer()}
</ModalFooter> </ModalFooter>
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts">
const {current, total}: {current: number; total: number} = $props()
</script>
<div class="flex w-full">
{#each Array(total) as _, i}
<div class="h-1 flex-1 transition-colors {i < current ? 'bg-primary' : 'bg-base-300'}"></div>
{/each}
</div>
+1 -2
View File
@@ -23,8 +23,7 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Reaction from "@app/components/Reaction.svelte" import Reaction from "@app/components/Reaction.svelte"
import ReportDetails from "@app/components/ReportDetails.svelte" import ReportDetails from "@app/components/ReportDetails.svelte"
import {deriveUserIsSpaceAdmin} from "@app/core/roles" import {REACTION_KINDS, deriveUserIsSpaceAdmin} from "@app/core/state"
import {REACTION_KINDS} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
interface Props { interface Props {
+1 -1
View File
@@ -12,7 +12,7 @@
import Popover from "@lib/components/Popover.svelte" import Popover from "@lib/components/Popover.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte" import Confirm from "@lib/components/Confirm.svelte"
import {deriveUserIsSpaceAdmin} from "@app/core/roles" import {deriveUserIsSpaceAdmin} from "@app/core/state"
import {publishDelete, canEnforceNip70} from "@app/core/commands" import {publishDelete, canEnforceNip70} from "@app/core/commands"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
-24
View File
@@ -1,24 +0,0 @@
<script lang="ts">
import cx from "classnames"
import type {RoleDefinition} from "@app/core/roles"
import {roleColorToCSS} from "@app/core/roles"
type Props = {
role: RoleDefinition
class?: string
}
const {role, ...props}: Props = $props()
const style = $derived(
role.color === undefined
? ""
: `color: ${roleColorToCSS(role.color)}; border-color: ${roleColorToCSS(role.color)};`,
)
const className = $derived(cx("badge badge-outline badge-sm", props.class))
</script>
<span class={className} {style}>
{role.label || role.name}
</span>
+8 -37
View File
@@ -27,22 +27,14 @@
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte"
import RoleBadge from "@app/components/RoleBadge.svelte"
import RoomMembers from "@app/components/RoomMembers.svelte" import RoomMembers from "@app/components/RoomMembers.svelte"
import RoomEdit from "@app/components/RoomEdit.svelte" import RoomEdit from "@app/components/RoomEdit.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import RoomImage from "@app/components/RoomImage.svelte" import RoomImage from "@app/components/RoomImage.svelte"
import {
deriveRoomMembers,
deriveRoomRoleDefinitions,
deriveUserIsRoomAdmin,
deriveHasPermission,
getRolePermissionsLabel,
getRoleAccessLabel,
ROOM_PERMISSION_EDIT_META,
} from "@app/core/roles"
import { import {
deriveRoom, deriveRoom,
deriveRoomMembers,
deriveUserIsRoomAdmin,
deriveUserRoomMembershipStatus, deriveUserRoomMembershipStatus,
deriveUserRooms, deriveUserRooms,
deriveShouldNotify, deriveShouldNotify,
@@ -66,9 +58,7 @@
const room = deriveRoom(url, h) const room = deriveRoom(url, h)
const members = deriveRoomMembers(url, h) const members = deriveRoomMembers(url, h)
const roleDefinitions = deriveRoomRoleDefinitions(url, h)
const userIsAdmin = deriveUserIsRoomAdmin(url, h) const userIsAdmin = deriveUserIsRoomAdmin(url, h)
const canEditMetadata = deriveHasPermission(url, h, ROOM_PERMISSION_EDIT_META)
const membershipStatus = deriveUserRoomMembershipStatus(url, h) const membershipStatus = deriveUserRoomMembershipStatus(url, h)
const userRooms = deriveUserRooms(url) const userRooms = deriveUserRooms(url)
@@ -162,7 +152,7 @@
<ul <ul
transition:fly transition:fly
class="bg-alt menu absolute right-0 z-popover w-48 gap-1 rounded-box p-2 shadow-md"> class="bg-alt menu absolute right-0 z-popover w-48 gap-1 rounded-box p-2 shadow-md">
{#if $canEditMetadata} {#if $userIsAdmin}
<li> <li>
<Button onclick={startEdit}> <Button onclick={startEdit}>
<Icon icon={Pen} /> <Icon icon={Pen} />
@@ -253,36 +243,17 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if $members.length > 0} {#if $members !== undefined && $members.length > 0}
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-4"> <div class="card2 card2-sm bg-alt flex items-center justify-between gap-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span>Members:</span> <span>Members:</span>
<ProfileCircles pubkeys={$members.map(member => member.pubkey)} /> <ProfileCircles pubkeys={$members} />
</div> </div>
<Button class="btn btn-neutral btn-sm" onclick={showMembers}>View All</Button> <Button class="btn btn-neutral btn-sm" onclick={showMembers}>View All</Button>
</div> </div>
{/if} {:else if $members === undefined}
{#if $userIsAdmin && $roleDefinitions.length > 0} <div class="card2 card2-sm bg-base-200 flex items-center gap-4">
<div class="card2 card2-sm bg-alt col-4"> <span class="text-error">Member list not available from this relay</span>
<strong class="text-lg">Role Definitions</strong>
<div class="flex flex-col gap-2">
{#each $roleDefinitions as role (role.name)}
<div class="rounded-box bg-base-300 p-3 flex flex-col gap-2">
<div class="flex items-center gap-2">
<RoleBadge {role} class="badge-md" />
{#if role.order !== undefined}
<span class="text-xs opacity-70">Order {role.order}</span>
{/if}
</div>
{#if role.permissions.length > 0}
<p class="text-xs opacity-75">Permissions: {getRolePermissionsLabel(role)}</p>
{/if}
{#if role.access.size > 0}
<p class="text-xs opacity-75">Access: {getRoleAccessLabel(role)}</p>
{/if}
</div>
{/each}
</div>
</div> </div>
{/if} {/if}
<div class="card2 card2-sm bg-alt col-4"> <div class="card2 card2-sm bg-alt col-4">
+1 -1
View File
@@ -13,7 +13,7 @@
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte" import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {deriveUserIsSpaceAdmin} from "@app/core/roles" import {deriveUserIsSpaceAdmin} from "@app/core/state"
type Props = { type Props = {
url: string url: string
+13 -39
View File
@@ -16,17 +16,9 @@
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import RoleBadge from "@app/components/RoleBadge.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import RoomMembersAdd from "@app/components/RoomMembersAdd.svelte" import RoomMembersAdd from "@app/components/RoomMembersAdd.svelte"
import { import {deriveRoom, deriveRoomMembers, deriveUserIsRoomAdmin} from "@app/core/state"
deriveRoomMembers,
deriveGroupedRoomMembers,
deriveHasPermission,
ROOM_PERMISSION_ADD_MEMBER,
ROOM_PERMISSION_REMOVE_MEMBER,
} from "@app/core/roles"
import {deriveRoom} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
@@ -39,9 +31,7 @@
const room = deriveRoom(url, h) const room = deriveRoom(url, h)
const members = deriveRoomMembers(url, h) const members = deriveRoomMembers(url, h)
const memberGroups = deriveGroupedRoomMembers(url, h) const userIsAdmin = deriveUserIsRoomAdmin(url, h)
const canAddMembers = deriveHasPermission(url, h, ROOM_PERMISSION_ADD_MEMBER)
const canRemoveMembers = deriveHasPermission(url, h, ROOM_PERMISSION_REMOVE_MEMBER)
const back = () => history.back() const back = () => history.back()
@@ -83,46 +73,32 @@
</ModalSubtitle> </ModalSubtitle>
</ModalHeader> </ModalHeader>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#if $members.length === 0} {#if $members === undefined}
<div class="card2 bg-base-200 p-4">
<span class="text-error">Member list not available from this relay</span>
</div>
{:else if $members.length === 0}
<div class="card2 bg-base-200 p-4"> <div class="card2 bg-base-200 p-4">
<span class="text-base-content/70">No members yet</span> <span class="text-base-content/70">No members yet</span>
</div> </div>
{:else} {:else}
{#each $memberGroups as group (group.key)} {#each $members as pubkey (pubkey)}
<div class="pt-2 pb-1">
{#if group.role}
<RoleBadge role={group.role} class="badge-md" />
{:else}
<span class="text-sm font-semibold opacity-75">Members</span>
{/if}
</div>
{#each group.members as member (member.pubkey)}
<div class="card2 bg-alt relative"> <div class="card2 bg-alt relative">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<Profile pubkey={member.pubkey} {url} /> <Profile {pubkey} {url} />
{#if member.roles.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each member.roles as role (role.name)}
<RoleBadge {role} />
{/each}
</div> </div>
{/if}
</div>
{#if $canRemoveMembers}
<div class="relative"> <div class="relative">
<Button <Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
class="btn btn-circle btn-ghost btn-sm"
onclick={() => toggleMenu(member.pubkey)}>
<Icon icon={MenuDots} /> <Icon icon={MenuDots} />
</Button> </Button>
{#if menuPubkey === member.pubkey} {#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}> <Popover hideOnClick onClose={closeMenu}>
<ul <ul
transition:fly transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md"> class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<li> <li>
<Button class="text-error" onclick={() => removeMember(member.pubkey)}> <Button class="text-error" onclick={() => removeMember(pubkey)}>
<Icon icon={MinusCircle} /> <Icon icon={MinusCircle} />
Remove Member Remove Member
</Button> </Button>
@@ -131,11 +107,9 @@
</Popover> </Popover>
{/if} {/if}
</div> </div>
{/if}
</div> </div>
</div> </div>
{/each} {/each}
{/each}
{/if} {/if}
</div> </div>
</ModalBody> </ModalBody>
@@ -144,7 +118,7 @@
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
{#if $canAddMembers} {#if $userIsAdmin}
<Button class="btn btn-primary" onclick={addMember}> <Button class="btn btn-primary" onclick={addMember}>
<Icon icon={AddCircle} /> <Icon icon={AddCircle} />
Add members Add members
+8 -6
View File
@@ -62,9 +62,10 @@
const flows = { const flows = {
email: { email: {
start: () => pushModal(SignUpEmail, {next: flows.email.profile}), start: () => pushModal(SignUpEmail, {next: flows.email.profile, step: 1, totalSteps: 3}),
profile: () => pushModal(SignUpProfile, {next: flows.email.complete}), profile: () => pushModal(SignUpProfile, {next: flows.email.complete, step: 2, totalSteps: 3}),
complete: () => pushModal(SignUpComplete, {next: flows.email.finalize}), complete: () =>
pushModal(SignUpComplete, {next: flows.email.finalize, step: 3, totalSteps: 3}),
finalize: () => { finalize: () => {
const email = getKey<string>("signup.email")! const email = getKey<string>("signup.email")!
const clientOptions = getKey<ClientOptions>("signup.clientOptions")! const clientOptions = getKey<ClientOptions>("signup.clientOptions")!
@@ -74,9 +75,10 @@
}, },
}, },
nostr: { nostr: {
start: () => pushModal(SignUpProfile, {next: flows.nostr.key}), start: () => pushModal(SignUpProfile, {next: flows.nostr.key, step: 1, totalSteps: 3}),
key: () => pushModal(SignUpKey, {next: flows.nostr.complete}), key: () => pushModal(SignUpKey, {next: flows.nostr.complete, step: 2, totalSteps: 3}),
complete: () => pushModal(SignUpComplete, {next: flows.nostr.finalize}), complete: () =>
pushModal(SignUpComplete, {next: flows.nostr.finalize, step: 3, totalSteps: 3}),
finalize: () => { finalize: () => {
const secret = getKey<string>("signup.secret")! const secret = getKey<string>("signup.secret")!
+7 -1
View File
@@ -9,12 +9,15 @@
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProgressBar from "@app/components/ProgressBar.svelte"
type Props = { type Props = {
next: () => void next: () => void
step?: number
totalSteps?: number
} }
const {next}: Props = $props() const {next, step, totalSteps}: Props = $props()
const back = () => history.back() const back = () => history.back()
</script> </script>
@@ -33,6 +36,9 @@
on groups you've already joined. Click below to get started! on groups you've already joined. Click below to get started!
</p> </p>
</ModalBody> </ModalBody>
{#if step && totalSteps}
<ProgressBar current={step} total={totalSteps} />
{/if}
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
+12 -3
View File
@@ -18,14 +18,17 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import SignUpEmailConfirm from "@app/components/SignUpEmailConfirm.svelte" import SignUpEmailConfirm from "@app/components/SignUpEmailConfirm.svelte"
import ProgressBar from "@app/components/ProgressBar.svelte"
import {pushToast, popToast} from "@app/util/toast" import {pushToast, popToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
type Props = { type Props = {
next: () => void next: () => void
step?: number
totalSteps?: number
} }
const {next}: Props = $props() const {next, step, totalSteps}: Props = $props()
const back = () => history.back() const back = () => history.back()
@@ -81,7 +84,7 @@
setKey("signup.clientOptions", clientOptions) setKey("signup.clientOptions", clientOptions)
popToast(toastId) popToast(toastId)
pushModal(SignUpEmailConfirm, {next}) pushModal(SignUpEmailConfirm, {next, step, totalSteps})
} catch (e) { } catch (e) {
console.error(e) console.error(e)
@@ -120,7 +123,7 @@
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Letter} /> <Icon icon={Letter} />
<input bind:value={email} /> <input type="email" bind:value={email} />
</label> </label>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
@@ -134,8 +137,14 @@
<input type="password" bind:value={password} /> <input type="password" bind:value={password} />
</label> </label>
{/snippet} {/snippet}
{#snippet info()}
Must be at least 12 characters long.
{/snippet}
</FieldInline> </FieldInline>
</ModalBody> </ModalBody>
{#if step && totalSteps}
<ProgressBar current={step} total={totalSteps} />
{/if}
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
+7 -1
View File
@@ -15,12 +15,15 @@
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProgressBar from "@app/components/ProgressBar.svelte"
type Props = { type Props = {
next: () => void next: () => void
step?: number
totalSteps?: number
} }
const {next}: Props = $props() const {next, step, totalSteps}: Props = $props()
const email = getKey<string>("signup.email") const email = getKey<string>("signup.email")
@@ -61,6 +64,9 @@
above. above.
</p> </p>
</ModalBody> </ModalBody>
{#if step && totalSteps}
<ProgressBar current={step} total={totalSteps} />
{/if}
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}> <Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
+4 -2
View File
@@ -4,11 +4,13 @@
type Props = { type Props = {
next: () => void next: () => void
step?: number
totalSteps?: number
} }
const {next}: Props = $props() const {next, step, totalSteps}: Props = $props()
const secret = getKey<string>("signup.secret")! const secret = getKey<string>("signup.secret")!
</script> </script>
<KeyDownload {secret} {next} /> <KeyDownload {secret} {next} {step} {totalSteps} />
+11 -9
View File
@@ -5,15 +5,16 @@
import {getKey, setKey} from "@lib/implicit" import {getKey, setKey} from "@lib/implicit"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte" import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
import ProgressBar from "@app/components/ProgressBar.svelte"
type Props = { type Props = {
next: () => void next: () => void
step?: number
totalSteps?: number
} }
const {next}: Props = $props() const {next, step, totalSteps}: Props = $props()
const profile = getKey<Profile>("signup.profile")! const profile = getKey<Profile>("signup.profile")!
@@ -27,9 +28,7 @@
} }
</script> </script>
<Modal> <ProfileEditForm isSignup {initialValues} {onsubmit}>
<ModalBody>
<ProfileEditForm isSignup {initialValues} {onsubmit}>
{#snippet footer()} {#snippet footer()}
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
@@ -40,6 +39,9 @@
<Icon icon={AltArrowRight} /> <Icon icon={AltArrowRight} />
</Button> </Button>
{/snippet} {/snippet}
</ProfileEditForm> {#snippet progressBar()}
</ModalBody> {#if step && totalSteps}
</Modal> <ProgressBar current={step} total={totalSteps} />
{/if}
{/snippet}
</ProfileEditForm>
+3 -3
View File
@@ -18,7 +18,7 @@
import SpaceRelayStatus from "@app/components/SpaceRelayStatus.svelte" import SpaceRelayStatus from "@app/components/SpaceRelayStatus.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte" import RelayDescription from "@app/components/RelayDescription.svelte"
import ProfileLatest from "@app/components/ProfileLatest.svelte" import ProfileLatest from "@app/components/ProfileLatest.svelte"
import {deriveUserHasSpacePermission, ROOM_PERMISSION_EDIT_META} from "@app/core/roles" import {deriveUserIsSpaceAdmin} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
type Props = { type Props = {
@@ -28,7 +28,7 @@
const {url}: Props = $props() const {url}: Props = $props()
const relay = deriveRelay(url) const relay = deriveRelay(url)
const owner = $derived($relay?.pubkey) const owner = $derived($relay?.pubkey)
const canEdit = deriveUserHasSpacePermission(url, ROOM_PERMISSION_EDIT_META) const userIsAdmin = deriveUserIsSpaceAdmin(url)
const back = () => history.back() const back = () => history.back()
@@ -54,7 +54,7 @@
<p class="ellipsize text-sm opacity-75">{displayRelayUrl(url)}</p> <p class="ellipsize text-sm opacity-75">{displayRelayUrl(url)}</p>
</div> </div>
</div> </div>
{#if $canEdit} {#if $userIsAdmin}
<Button class="btn btn-primary" onclick={startEdit}> <Button class="btn btn-primary" onclick={startEdit}>
<Icon icon={Pen} /> <Icon icon={Pen} />
Edit Edit
+22 -2
View File
@@ -25,6 +25,16 @@
const {url} = $props() const {url} = $props()
const authError = deriveRelayAuthError(url) const authError = deriveRelayAuthError(url)
let networkError = $state(false)
const isExplicitAuthError = $derived(
$authError &&
!(
$authError.toLowerCase().includes("failed") ||
$authError.toLowerCase().includes("timeout") ||
$authError.toLowerCase().includes("network")
),
)
const isGenericError = $derived(networkError || ($authError && !isExplicitAuthError))
const back = () => history.back() const back = () => history.back()
const copyInvite = () => clip(invite) const copyInvite = () => clip(invite)
@@ -70,8 +80,14 @@
]) ])
claim = getTagValue("claim", event?.tags || []) || "" claim = getTagValue("claim", event?.tags || []) || ""
} catch { } catch (err) {
claim = "" claim = ""
if (
(err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError")) ||
!navigator.onLine
) {
networkError = true
}
} finally { } finally {
loading = false loading = false
} }
@@ -92,7 +108,11 @@
<p class="center"> <p class="center">
<Spinner {loading}>Requesting an invite link...</Spinner> <Spinner {loading}>Requesting an invite link...</Spinner>
</p> </p>
{:else if $authError} {:else if isGenericError}
<p class="center text-center">
Unable to reach the relay. Please check your connection and try again.
</p>
{:else if isExplicitAuthError}
<p class="center">Oops! It looks like you're not a member of this relay.</p> <p class="center">Oops! It looks like you're not a member of this relay.</p>
{:else} {:else}
<div class="flex flex-col items-center gap-6"> <div class="flex flex-col items-center gap-6">
+20 -43
View File
@@ -17,20 +17,16 @@
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import RoleBadge from "@app/components/RoleBadge.svelte"
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import SpaceMembersAdd from "@app/components/SpaceMembersAdd.svelte" import SpaceMembersAdd from "@app/components/SpaceMembersAdd.svelte"
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte" import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte"
import {deriveSpaceMembers, deriveSpaceBannedPubkeyItems} from "@app/core/state"
import { import {
deriveGroupedSpaceMembers, deriveSpaceMembers,
deriveSpaceBannedPubkeyItems,
deriveUserIsSpaceAdmin,
deriveSupportedMethods, deriveSupportedMethods,
deriveUserHasSpacePermission, } from "@app/core/state"
ROOM_PERMISSION_ADD_MEMBER,
ROOM_PERMISSION_REMOVE_MEMBER,
ROOM_PERMISSION_BAN_USER,
} from "@app/core/roles"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
@@ -42,17 +38,10 @@
const members = deriveSpaceMembers(url) const members = deriveSpaceMembers(url)
const bans = deriveSpaceBannedPubkeyItems(url) const bans = deriveSpaceBannedPubkeyItems(url)
const memberGroups = deriveGroupedSpaceMembers(url, members) const userIsAdmin = deriveUserIsSpaceAdmin(url)
const canAddMember = deriveUserHasSpacePermission(url, ROOM_PERMISSION_ADD_MEMBER)
const canBanByPermission = deriveUserHasSpacePermission(url, ROOM_PERMISSION_BAN_USER)
const canUnallowByPermission = deriveUserHasSpacePermission(url, ROOM_PERMISSION_REMOVE_MEMBER)
const supportedMethods = deriveSupportedMethods(url) const supportedMethods = deriveSupportedMethods(url)
const canBan = $derived( const canBan = $derived($supportedMethods.includes(ManagementMethod.BanPubkey))
$canBanByPermission && $supportedMethods.includes(ManagementMethod.BanPubkey), const canUnallow = $derived($supportedMethods.includes(ManagementMethod.UnallowPubkey))
)
const canUnallow = $derived(
$canUnallowByPermission && $supportedMethods.includes(ManagementMethod.UnallowPubkey),
)
const back = () => history.back() const back = () => history.back()
@@ -115,7 +104,7 @@
<ModalTitle>Members</ModalTitle> <ModalTitle>Members</ModalTitle>
<ModalSubtitle>of <RelayName {url} class="text-primary" /></ModalSubtitle> <ModalSubtitle>of <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader> </ModalHeader>
{#if canBan || canUnallow} {#if $userIsAdmin}
{#if $bans.length > 0} {#if $bans.length > 0}
<Button class="btn btn-neutral" onclick={showBannedPubkeyItems}> <Button class="btn btn-neutral" onclick={showBannedPubkeyItems}>
Banned users ({$bans.length}) Banned users ({$bans.length})
@@ -123,47 +112,36 @@
{/if} {/if}
{/if} {/if}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#if $members.length === 0} {#if $members === undefined}
<div class="card2 bg-base-200 p-4">
<span class="text-error">Member list not available from this space</span>
</div>
{:else if $members.length === 0}
<div class="card2 bg-base-200 p-4"> <div class="card2 bg-base-200 p-4">
<span class="text-base-content/70">No members yet</span> <span class="text-base-content/70">No members yet</span>
</div> </div>
{:else} {:else}
{#each $memberGroups as group (group.key)} {#each $members as pubkey (pubkey)}
<div class="pt-2 pb-1">
{#if group.role}
<RoleBadge role={group.role} class="badge-md" />
{:else}
<span class="text-sm font-semibold opacity-75">Members</span>
{/if}
</div>
{#each group.members as member (member.pubkey)}
<div class="card2 card2-sm bg-alt relative"> <div class="card2 card2-sm bg-alt relative">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<Profile pubkey={member.pubkey} {url} /> <Profile {pubkey} {url} />
{#if member.roles.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each member.roles as role (role.name)}
<RoleBadge {role} />
{/each}
</div>
{/if}
</div> </div>
{#if canBan || canUnallow} {#if canBan || canUnallow}
<div class="relative"> <div class="relative">
<Button <Button
class="btn btn-circle btn-ghost btn-sm" class="btn btn-circle btn-ghost btn-sm"
onclick={() => toggleMenu(member.pubkey)}> onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} /> <Icon icon={MenuDots} />
</Button> </Button>
{#if menuPubkey === member.pubkey} {#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}> <Popover hideOnClick onClose={closeMenu}>
<ul <ul
transition:fly transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md"> class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
{#if canUnallow} {#if canUnallow}
<li> <li>
<Button onclick={() => unallowMember(member.pubkey)}> <Button onclick={() => unallowMember(pubkey)}>
<Icon icon={UserMinus} /> <Icon icon={UserMinus} />
Remove User Remove User
</Button> </Button>
@@ -171,7 +149,7 @@
{/if} {/if}
{#if canBan} {#if canBan}
<li> <li>
<Button class="text-error" onclick={() => banMember(member.pubkey)}> <Button class="text-error" onclick={() => banMember(pubkey)}>
<Icon icon={MinusCircle} /> <Icon icon={MinusCircle} />
Ban User Ban User
</Button> </Button>
@@ -185,7 +163,6 @@
</div> </div>
</div> </div>
{/each} {/each}
{/each}
{/if} {/if}
</div> </div>
</ModalBody> </ModalBody>
@@ -194,7 +171,7 @@
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
{#if $canAddMember} {#if $userIsAdmin}
<Button class="btn btn-primary" onclick={addMember}> <Button class="btn btn-primary" onclick={addMember}>
<Icon icon={AddCircle} /> <Icon icon={AddCircle} />
Add members Add members
+1 -2
View File
@@ -16,8 +16,7 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import {deriveSupportedMethods} from "@app/core/roles" import {deriveSpaceBannedPubkeyItems, deriveSupportedMethods} from "@app/core/state"
import {deriveSpaceBannedPubkeyItems} from "@app/core/state"
import {addSpaceMembers} from "@app/core/commands" import {addSpaceMembers} from "@app/core/commands"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
+1 -1
View File
@@ -40,7 +40,6 @@
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte" import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte" import VoiceWidget from "@app/components/VoiceWidget.svelte"
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte" import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
import {deriveUserIsSpaceAdmin} from "@app/core/roles"
import { import {
ENABLE_ZAPS, ENABLE_ZAPS,
CONTENT_KINDS, CONTENT_KINDS,
@@ -51,6 +50,7 @@
userSpaceUrls, userSpaceUrls,
hasNip29, hasNip29,
deriveUserCanCreateRoom, deriveUserCanCreateRoom,
deriveUserIsSpaceAdmin,
deriveEventsForUrl, deriveEventsForUrl,
deriveSpaceActionItems, deriveSpaceActionItems,
notificationSettings, notificationSettings,
+16 -7
View File
@@ -2,9 +2,10 @@
import {tick} from "svelte" import {tick} from "svelte"
import {debounce} from "throttle-debounce" import {debounce} from "throttle-debounce"
import {request} from "@welshman/net" import {request} from "@welshman/net"
import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib" import {repository, tracker} from "@welshman/app"
import {formatTimestampAsDate, groupBy, uniqBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
import type {TrustedEvent, Filter} from "@welshman/util" import type {TrustedEvent, Filter} from "@welshman/util"
import {sortEventsDesc} from "@welshman/util" import {MESSAGE, sortEventsDesc} from "@welshman/util"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
@@ -53,8 +54,11 @@
const getFilter = (searchTerm: string): Filter => const getFilter = (searchTerm: string): Filter =>
h h
? {kinds: CONTENT_KINDS, "#h": [h], search: searchTerm} ? {kinds: [MESSAGE, ...CONTENT_KINDS], "#h": [h], search: searchTerm}
: {kinds: CONTENT_KINDS, search: searchTerm} : {kinds: [MESSAGE, ...CONTENT_KINDS], search: searchTerm}
const getLocalResults = (filter: Filter) =>
repository.query([filter]).filter(event => tracker.getRelays(event.id).has(url))
const search = debounce(300, async (searchTerm: string) => { const search = debounce(300, async (searchTerm: string) => {
controller?.abort() controller?.abort()
@@ -68,18 +72,23 @@
controller = new AbortController() controller = new AbortController()
loading = true loading = true
const filter = getFilter(searchTerm.trim())
const localResults = getLocalResults(filter)
results = sortEventsDesc(localResults)
try { try {
const events = await request({ const events = await request({
relays: getRelayUrls(), relays: getRelayUrls(),
autoClose: true, autoClose: true,
signal: controller.signal, signal: controller.signal,
filters: [getFilter(searchTerm.trim())], filters: [filter],
}) })
results = sortEventsDesc(events) results = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, [...events, ...localResults]))
} catch (error) { } catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) { if (!(error instanceof DOMException && error.name === "AbortError")) {
results = [] results = sortEventsDesc(localResults)
} }
} finally { } finally {
loading = false loading = false
-576
View File
@@ -1,576 +0,0 @@
import {derived, readable, type Readable} from "svelte/store"
import {first, memoize, removeUndefined, simpleCache, sortBy, uniq} from "@welshman/lib"
import {pubkey, manageRelay} from "@welshman/app"
import {deriveEventsForUrl} from "@app/core/state"
import {
ManagementMethod,
ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER,
ROOM_EDIT_META,
ROOM_DELETE_EVENT,
ROOM_ADMINS,
ROOM_MEMBERS,
getTagValue,
isRelayUrl,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
export const ROOM_ROLES = 39003
export const ROOM_PERMISSION_ADD_MEMBER = ROOM_ADD_MEMBER
export const ROOM_PERMISSION_REMOVE_MEMBER = ROOM_REMOVE_MEMBER
export const ROOM_PERMISSION_EDIT_META = ROOM_EDIT_META
export const ROOM_PERMISSION_DELETE_EVENT = ROOM_DELETE_EVENT
export const ROOM_PERMISSION_BAN_USER = 9009
const ALL_ROOM_PERMISSIONS = [
ROOM_PERMISSION_ADD_MEMBER,
ROOM_PERMISSION_REMOVE_MEMBER,
ROOM_PERMISSION_EDIT_META,
ROOM_PERMISSION_DELETE_EVENT,
ROOM_PERMISSION_BAN_USER,
]
export const deriveSupportedMethods = simpleCache(([url]: [string]) =>
readable<ManagementMethod[]>([], set => {
manageRelay(url, {
method: ManagementMethod.SupportedMethods,
params: [],
}).then(({result = []}) => set(result))
}),
)
export type RoleAccess = "read" | "write" | "join"
export type RoleDefinition = {
name: string
label?: string
color?: number
order?: number
permissions: number[]
access: Set<RoleAccess>
}
export type RoomRoles = {
url: string
h: string
roles: Map<string, RoleDefinition>
}
export type RoomMember = {
pubkey: string
roles: string[]
}
type MemberRoleInfo = {
pubkey: string
roles: RoleDefinition[]
primaryRole?: RoleDefinition
}
type MemberRoleGroup = {
key: string
role?: RoleDefinition
members: MemberRoleInfo[]
}
type ParsedRoleState = {
roles: Map<string, RoleDefinition>
hasPermissionTags: boolean
}
type SpaceRoleState = {
hasPermissionTags: boolean
userPermissions: Set<number>
memberRoles: Map<string, RoleDefinition[]>
}
const makeRoleDefinition = (name: string): RoleDefinition => ({
name,
permissions: [],
access: new Set<RoleAccess>(),
})
const ensureRole = (roles: Map<string, RoleDefinition>, roleName: string) => {
if (!roles.has(roleName)) {
roles.set(roleName, makeRoleDefinition(roleName))
}
return roles.get(roleName)!
}
const asNumber = (value: string | undefined) => {
const n = parseInt(value || "")
return isNaN(n) ? undefined : n
}
const asAccess = (value: string | undefined): RoleAccess | undefined => {
if (value === "read" || value === "write" || value === "join") {
return value
}
}
const parseRoleState = (event?: TrustedEvent): ParsedRoleState => {
const roles = new Map<string, RoleDefinition>()
let hasPermissionTags = false
let activeRoleName: string | undefined
for (const tag of event?.tags || []) {
const [name, firstValue, secondValue] = tag
if (name === "role") {
if (firstValue) {
activeRoleName = firstValue
ensureRole(roles, firstValue)
}
continue
}
if (
!["role-label", "role-color", "role-order", "role-permission", "role-access"].includes(name)
) {
continue
}
const hasExplicitRole = Boolean(firstValue && secondValue !== undefined)
const roleName = hasExplicitRole ? firstValue : activeRoleName
const value = hasExplicitRole ? secondValue : firstValue
if (!roleName || !value) {
continue
}
const role = ensureRole(roles, roleName)
if (name === "role-label") {
role.label = value
continue
}
if (name === "role-color") {
const color = asNumber(value)
if (color !== undefined && color >= 0 && color <= 255) {
role.color = color
}
continue
}
if (name === "role-order") {
const order = asNumber(value)
if (order !== undefined) {
role.order = order
}
continue
}
if (name === "role-permission") {
const permission = asNumber(value)
hasPermissionTags = true
if (permission !== undefined && !role.permissions.includes(permission)) {
role.permissions.push(permission)
}
continue
}
if (name === "role-access") {
const access = asAccess(value)
if (access) {
role.access.add(access)
}
}
}
return {roles, hasPermissionTags}
}
const getRoleTokens = (tag: string[]) => {
const roles: string[] = []
for (const value of tag.slice(2)) {
if (!value || isRelayUrl(value)) {
continue
}
roles.push(value)
}
return uniq(roles)
}
export const parseRoomMembers = (tags: string[][]) => {
const byPubkey = new Map<string, Set<string>>()
for (const tag of tags) {
if (tag[0] !== "p" || !tag[1]) {
continue
}
if (!byPubkey.has(tag[1])) {
byPubkey.set(tag[1], new Set<string>())
}
const roles = byPubkey.get(tag[1])!
for (const role of getRoleTokens(tag)) {
roles.add(role)
}
}
return Array.from(byPubkey.entries()).map(([pubkey, roles]) => ({
pubkey,
roles: Array.from(roles),
}))
}
export const deriveRoomMembers = (url: string, h: string) =>
derived(deriveEventsForUrl(url, [{kinds: [ROOM_MEMBERS], "#d": [h]}]), ([event]) =>
parseRoomMembers(event?.tags || []),
)
export const deriveRoomAdmins = (url: string, h: string) =>
derived(deriveEventsForUrl(url, [{kinds: [ROOM_ADMINS], "#d": [h]}]), ([event]) =>
parseRoomMembers(event?.tags || []),
)
const deriveRoomRoleState = simpleCache(([url, h]: [string, string]) =>
derived(deriveEventsForUrl(url, [{kinds: [ROOM_ROLES], "#d": [h]}]), ([event]) =>
parseRoleState(event),
),
)
export const deriveRoomRoles = (url: string, h: string) =>
derived(deriveRoomRoleState(url, h), $state => ({
url,
h,
roles: $state.roles,
}))
const getMember = (members: RoomMember[], targetPubkey: string) =>
members.find(member => member.pubkey === targetPubkey)
const getResolvedRoles = (rolesByName: Map<string, RoleDefinition>, roleNames: string[]) =>
removeUndefined(roleNames.map(name => rolesByName.get(name)))
export const sortRolesDesc = <T extends {order?: number}>(items: T[]) =>
sortBy(item => -(item.order ?? -Infinity), items)
export const getRolePermissionsLabel = (role: RoleDefinition) => role.permissions.join(", ")
export const getRoleAccessLabel = (role: RoleDefinition) => Array.from(role.access).join(", ")
const toMemberRoleInfo = (pubkey: string, roles: RoleDefinition[]): MemberRoleInfo => {
const sortedRoles = sortRolesDesc(roles)
return {
pubkey,
roles: sortedRoles,
primaryRole: first(sortedRoles),
}
}
const sortMemberRoleInfos = (members: MemberRoleInfo[]) =>
sortBy(member => -(member.primaryRole?.order ?? -Infinity), members)
const groupMemberRoleInfos = (members: MemberRoleInfo[]) => {
const byRole = new Map<string, MemberRoleGroup>()
const ungrouped: MemberRoleGroup = {
key: "members",
members: [],
}
for (const member of sortMemberRoleInfos(members)) {
if (!member.primaryRole) {
ungrouped.members.push(member)
continue
}
const key = member.primaryRole.name
if (!byRole.has(key)) {
byRole.set(key, {
key,
role: member.primaryRole,
members: [],
})
}
byRole.get(key)!.members.push(member)
}
const groups = sortBy(group => -(group.role?.order ?? -Infinity), Array.from(byRole.values()))
if (ungrouped.members.length > 0) {
groups.push(ungrouped)
}
return groups
}
const deriveRoomRoleAssignments = simpleCache(([url, h]: [string, string]) =>
derived(
[deriveRoomRoleState(url, h), deriveRoomMembers(url, h), deriveRoomAdmins(url, h)],
([$rolesState, $members, $admins]) => ({
rolesState: $rolesState,
members: $members,
admins: $admins,
}),
),
)
export const deriveUserRoles = (url: string, h: string, targetPubkey: string) =>
derived(deriveRoomRoleAssignments(url, h), ({members, admins}) => {
const member = getMember(members, targetPubkey)
const admin = getMember(admins, targetPubkey)
return uniq([...(member?.roles || []), ...(admin?.roles || [])])
})
export const deriveUserPermissions = (url: string, h: string) =>
derived(
[pubkey, deriveRoomRoleAssignments(url, h)],
([$pubkey, {rolesState, members, admins}]) => {
const permissions = new Set<number>()
if (!$pubkey) {
return permissions
}
const member = getMember(members, $pubkey)
const admin = getMember(admins, $pubkey)
const assignedRoleNames = uniq([...(member?.roles || []), ...(admin?.roles || [])])
if (!rolesState.hasPermissionTags) {
if (admin) {
for (const permission of ALL_ROOM_PERMISSIONS) {
permissions.add(permission)
}
}
return permissions
}
for (const role of getResolvedRoles(rolesState.roles, assignedRoleNames)) {
for (const permission of role.permissions) {
permissions.add(permission)
}
}
return permissions
},
)
const mergeRoleDefinitions = (left: RoleDefinition[], right: RoleDefinition[]) => {
const merged = new Map<string, RoleDefinition>()
for (const role of [...left, ...right]) {
if (!merged.has(role.name)) {
merged.set(role.name, {
name: role.name,
label: role.label,
color: role.color,
order: role.order,
permissions: [...role.permissions],
access: new Set(role.access),
})
continue
}
const existing = merged.get(role.name)!
if (existing.label === undefined) {
existing.label = role.label
}
if (existing.color === undefined) {
existing.color = role.color
}
if (existing.order === undefined) {
existing.order = role.order
}
existing.permissions = uniq([...existing.permissions, ...role.permissions])
for (const access of role.access) {
existing.access.add(access)
}
}
return sortBy(role => role.name, Array.from(merged.values()))
}
const deriveSpaceRoleState = simpleCache(([url]: [string]) =>
derived(
[pubkey, deriveEventsForUrl(url, [{kinds: [ROOM_ROLES, ROOM_MEMBERS, ROOM_ADMINS]}])],
([$pubkey, $events]): SpaceRoleState => {
const userPermissions = new Set<number>()
const memberRoles = new Map<string, RoleDefinition[]>()
const rolesByH = new Map<string, ReturnType<typeof parseRoleState>>()
let hasPermissionTags = false
for (const event of $events) {
if (event.kind === ROOM_ROLES) {
const h = getTagValue("d", event.tags)
if (h) {
const parsed = parseRoleState(event)
rolesByH.set(h, parsed)
if (parsed.hasPermissionTags) {
hasPermissionTags = true
}
}
}
}
for (const event of $events) {
if (event.kind === ROOM_MEMBERS || event.kind === ROOM_ADMINS) {
const h = getTagValue("d", event.tags)
if (!h) continue
const rolesState = rolesByH.get(h)
if (!rolesState) continue
const members = parseRoomMembers(event.tags)
for (const member of members) {
const resolvedRoles = getResolvedRoles(rolesState.roles, member.roles)
if (resolvedRoles.length === 0) {
continue
}
memberRoles.set(
member.pubkey,
mergeRoleDefinitions(memberRoles.get(member.pubkey) || [], resolvedRoles),
)
if ($pubkey === member.pubkey && rolesState.hasPermissionTags) {
for (const role of resolvedRoles) {
for (const permission of role.permissions) {
userPermissions.add(permission)
}
}
}
}
}
}
return {
hasPermissionTags,
userPermissions,
memberRoles,
}
},
),
)
export const deriveUserIsSpaceAdmin = memoize((url?: string) => {
if (!url) {
return readable(false)
}
return derived(
[deriveSpaceRoleState(url), deriveSupportedMethods(url)],
([$spaceRoleState, $supportedMethods]) => {
if ($spaceRoleState.hasPermissionTags) {
return $spaceRoleState.userPermissions.size > 0
}
return $supportedMethods.length > 0
},
)
})
export const deriveUserSpacePermissions = (url: string) =>
derived(
[deriveSpaceRoleState(url), deriveUserIsSpaceAdmin(url)],
([$spaceRoleState, $isAdmin]) => {
const permissions = new Set<number>()
if ($spaceRoleState.hasPermissionTags) {
for (const permission of $spaceRoleState.userPermissions) {
permissions.add(permission)
}
return permissions
}
if ($isAdmin) {
for (const permission of ALL_ROOM_PERMISSIONS) {
permissions.add(permission)
}
}
return permissions
},
)
export const deriveUserHasSpacePermission = (url: string, kind: number) =>
derived(deriveUserSpacePermissions(url), $permissions => $permissions.has(kind))
export const deriveUserIsRoomAdmin = (url: string, h: string) =>
derived(
[deriveUserPermissions(url, h), deriveUserIsSpaceAdmin(url)],
([$permissions, $isSpaceAdmin]) => $isSpaceAdmin || $permissions.size > 0,
)
export const deriveHasPermission = (url: string, h: string | undefined, kind: number) => {
if (!h) return deriveUserIsSpaceAdmin(url)
return derived(
[deriveUserPermissions(url, h), deriveUserIsSpaceAdmin(url)],
([$permissions, $isSpaceAdmin]) => $isSpaceAdmin || $permissions.has(kind),
)
}
export const deriveRoomRoleDefinitions = (url: string, h: string) =>
derived(deriveRoomRoles(url, h), $roomRoles =>
sortRolesDesc(Array.from($roomRoles.roles.values())),
)
const deriveRoomMemberRoleInfo = (url: string, h: string) =>
derived([deriveRoomMembers(url, h), deriveRoomRoles(url, h)], ([$members, $roomRoles]) =>
sortMemberRoleInfos(
$members.map(member =>
toMemberRoleInfo(member.pubkey, getResolvedRoles($roomRoles.roles, member.roles)),
),
),
)
export const deriveGroupedRoomMembers = (url: string, h: string) =>
derived(deriveRoomMemberRoleInfo(url, h), $members => groupMemberRoleInfos($members))
const deriveSpaceMemberRoleInfo = (url: string) =>
derived(deriveSpaceRoleState(url), $spaceRoleState => {
const roleInfoByPubkey = new Map<string, MemberRoleInfo>()
for (const [pubkey, roles] of $spaceRoleState.memberRoles.entries()) {
roleInfoByPubkey.set(pubkey, toMemberRoleInfo(pubkey, roles))
}
return roleInfoByPubkey
})
export const deriveSpaceMemberRoles = (url: string, targetPubkey: string) =>
derived(
deriveSpaceMemberRoleInfo(url),
$spaceMemberRoles => $spaceMemberRoles.get(targetPubkey)?.roles || [],
)
export const deriveGroupedSpaceMembers = (url: string, members: Readable<string[]>) =>
derived([members, deriveSpaceMemberRoleInfo(url)], ([$members, $spaceMemberRoles]) =>
groupMemberRoleInfos(
$members.map(pubkey => $spaceMemberRoles.get(pubkey) || toMemberRoleInfo(pubkey, [])),
),
)
export const roleColorToCSS = (hue: number) => `oklch(0.75 0.15 ${(hue * 360) / 255})`
+56 -35
View File
@@ -20,6 +20,7 @@ import {
partition, partition,
shuffle, shuffle,
parseJson, parseJson,
memoize,
addToMapKey, addToMapKey,
identity, identity,
always, always,
@@ -83,6 +84,7 @@ import {
ROOM_JOIN, ROOM_JOIN,
ROOM_LEAVE, ROOM_LEAVE,
ROOM_MEMBERS, ROOM_MEMBERS,
ROOM_ADMINS,
ROOM_META, ROOM_META,
ROOM_DELETE, ROOM_DELETE,
ROOM_REMOVE_MEMBER, ROOM_REMOVE_MEMBER,
@@ -150,18 +152,6 @@ import {
} from "@welshman/app" } from "@welshman/app"
import {checkRelayHasLivekit} from "$lib/livekit" import {checkRelayHasLivekit} from "$lib/livekit"
import {readFeed} from "@lib/feeds" import {readFeed} from "@lib/feeds"
import {
parseRoomMembers,
deriveRoomMembers,
deriveUserIsSpaceAdmin,
deriveUserIsRoomAdmin,
deriveUserSpacePermissions,
ROOM_PERMISSION_ADD_MEMBER,
ROOM_PERMISSION_REMOVE_MEMBER,
ROOM_PERMISSION_DELETE_EVENT,
ROOM_PERMISSION_BAN_USER,
} from "@app/core/roles"
import type {RoomMember} from "@app/core/roles"
export const fromCsv = (s: string) => (s || "").split(",").filter(identity) export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
@@ -823,6 +813,14 @@ export const deriveSpaceMembers = (url: string) =>
uniq(getTagValues("member", event?.tags ?? [])), uniq(getTagValues("member", event?.tags ?? [])),
) )
export const deriveRoomMembers = (url: string, h: string) => {
const filters: Filter[] = [{kinds: [ROOM_MEMBERS], "#d": [h]}]
return derived(deriveEventsForUrl(url, filters), ([event]) =>
uniq(getPubkeyTagValues(event?.tags ?? [])),
)
}
export type BannedPubkeyItem = { export type BannedPubkeyItem = {
pubkey: string pubkey: string
reason: string reason: string
@@ -841,6 +839,20 @@ export const deriveSpaceBannedPubkeyItems = (url: string) => {
return store return store
} }
export const deriveRoomAdmins = (url: string, h: string) => {
const filters: Filter[] = [{kinds: [ROOM_ADMINS], "#d": [h]}]
return derived(deriveEventsForUrl(url, filters), $events => {
const adminsEvent = first($events)
if (adminsEvent) {
return getPubkeyTagValues(adminsEvent.tags)
}
return []
})
}
const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => { const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => {
const members = new Set<string>() const members = new Set<string>()
@@ -848,8 +860,8 @@ const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => {
if (event.kind === ROOM_MEMBERS && getTagValue("d", event.tags) === h) { if (event.kind === ROOM_MEMBERS && getTagValue("d", event.tags) === h) {
members.clear() members.clear()
for (const member of parseRoomMembers(event.tags)) { for (const pubkey of uniq(getPubkeyTagValues(event.tags))) {
members.add(member.pubkey) members.add(pubkey)
} }
continue continue
@@ -882,31 +894,16 @@ const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => {
export const deriveSpaceActionItems = (url: string) => export const deriveSpaceActionItems = (url: string) =>
derived( derived(
[
deriveEventsForUrl(url, [ deriveEventsForUrl(url, [
{ {
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
}, },
]), ]),
deriveUserIsSpaceAdmin(url), $events => {
deriveUserSpacePermissions(url),
],
([$events, $isAdmin, $permissions]) => {
if (!$isAdmin) {
return []
}
const getRoomId = (e: TrustedEvent) => const getRoomId = (e: TrustedEvent) =>
getTagValue(e.kind === ROOM_MEMBERS ? "d" : "h", e.tags) getTagValue(e.kind === ROOM_MEMBERS ? "d" : "h", e.tags)
const reports = $events.filter(e => e.kind === REPORT) const reports = $events.filter(e => e.kind === REPORT)
const pendingJoins: TrustedEvent[] = [] const pendingJoins: TrustedEvent[] = []
const canReviewReports =
$permissions.has(ROOM_PERMISSION_DELETE_EVENT) || $permissions.size === 0
const canReviewJoins =
$permissions.has(ROOM_PERMISSION_ADD_MEMBER) ||
$permissions.has(ROOM_PERMISSION_REMOVE_MEMBER) ||
$permissions.has(ROOM_PERMISSION_BAN_USER) ||
$permissions.size === 0
// Room-level join requests — most recent per pubkey+h // Room-level join requests — most recent per pubkey+h
for (const [h, roomEvents] of groupBy(getRoomId, $events)) { for (const [h, roomEvents] of groupBy(getRoomId, $events)) {
@@ -963,10 +960,7 @@ export const deriveSpaceActionItems = (url: string) =>
) )
} }
return sortEventsDesc([ return sortEventsDesc([...reports, ...pendingJoins])
...(canReviewReports ? reports : []),
...(canReviewJoins ? pendingJoins : []),
])
}, },
) )
@@ -978,6 +972,18 @@ export enum MembershipStatus {
Granted, Granted,
} }
export const deriveUserIsSpaceAdmin = memoize((url?: string) => {
const store = writable(false)
if (url) {
manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}).then(res =>
store.set(Boolean(res.result?.length)),
)
}
return store
})
export const deriveUserSpaceMembershipStatus = (url: string) => { export const deriveUserSpaceMembershipStatus = (url: string) => {
// Fetch member list and user add/remove events directly in this derivation. // Fetch member list and user add/remove events directly in this derivation.
const memberListFilters: Filter[] = [{kinds: [RELAY_MEMBERS]}] const memberListFilters: Filter[] = [{kinds: [RELAY_MEMBERS]}]
@@ -1040,6 +1046,12 @@ export const deriveUserSpaceMembershipStatus = (url: string) => {
) )
} }
export const deriveUserIsRoomAdmin = (url: string, h: string) =>
derived(
[pubkey, deriveRoomAdmins(url, h), deriveUserIsSpaceAdmin(url)],
([$pubkey, $admins, $isSpaceAdmin]) => $isSpaceAdmin || $admins.includes($pubkey!),
)
export const deriveUserRoomMembershipStatus = (url: string, h: string) => { export const deriveUserRoomMembershipStatus = (url: string, h: string) => {
// Fetch the room member list and the current user's add/remove events. // Fetch the room member list and the current user's add/remove events.
const userEventFilters: Filter[] = [{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}] const userEventFilters: Filter[] = [{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}]
@@ -1063,7 +1075,7 @@ export const deriveUserRoomMembershipStatus = (url: string, h: string) => {
if ($memberList) { if ($memberList) {
// Member list exists - check if user is in it. // Member list exists - check if user is in it.
isMember = $memberList.some((member: RoomMember) => member.pubkey === $pubkey) isMember = $memberList.includes($pubkey!)
} else { } else {
// No member list available - replay the user's add/remove history. // No member list available - replay the user's add/remove history.
for (const event of sortEventsAsc($userAddRemoveEvents)) { for (const event of sortEventsAsc($userAddRemoveEvents)) {
@@ -1300,6 +1312,15 @@ export const deriveSocketStatus = (url: string) =>
}), }),
) )
export const deriveSupportedMethods = simpleCache(([url]: [string]) => {
return readable<ManagementMethod[]>([], set => {
manageRelay(url, {
method: ManagementMethod.SupportedMethods,
params: [],
}).then(({result = []}) => set(result))
})
})
export const deriveHasLivekit = simpleCache(([url]: [string]) => export const deriveHasLivekit = simpleCache(([url]: [string]) =>
readable<boolean | undefined>(undefined, set => { readable<boolean | undefined>(undefined, set => {
checkRelayHasLivekit(url).then(has => set(has)) checkRelayHasLivekit(url).then(has => set(has))
+1 -2
View File
@@ -55,7 +55,6 @@ import {
makeCommentFilter, makeCommentFilter,
loadFeedsForPubkey, loadFeedsForPubkey,
} from "@app/core/state" } from "@app/core/state"
import {ROOM_ROLES} from "@app/core/roles"
import {hasBlossomSupport} from "@app/core/commands" import {hasBlossomSupport} from "@app/core/commands"
import {LIVEKIT_PARTICIPANTS} from "@app/call/voice" import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
@@ -272,7 +271,7 @@ const syncSpace = (url: string) => {
const since = ago(WEEK) const since = ago(WEEK)
const controller = new AbortController() const controller = new AbortController()
const relayKinds = [RELAY_MEMBERS] const relayKinds = [RELAY_MEMBERS]
const roomMetaKinds = [ROOM_META, ROOM_ROLES, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS] const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS]
const roomDeleteKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE] const roomDeleteKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE]
pullAndListen({ pullAndListen({
+11
View File
@@ -0,0 +1,11 @@
export const POMADE_INVALID_LOGIN_MESSAGE = "Invalid login information"
export const POMADE_NETWORK_ERROR_MESSAGE = "Network error, please try again"
type PomadeMessage = {
res?: unknown
}
export const getPomadeLoginFailureMessage = (messages: PomadeMessage[]) =>
messages.some(message => message.res !== undefined)
? POMADE_INVALID_LOGIN_MESSAGE
: POMADE_NETWORK_ERROR_MESSAGE
-2
View File
@@ -47,7 +47,6 @@ import {
} from "@welshman/app" } from "@welshman/app"
import type {Unsubscriber} from "svelte/store" import type {Unsubscriber} from "svelte/store"
import {db} from "@app/core/storage" import {db} from "@app/core/storage"
import {ROOM_ROLES} from "@app/core/roles"
// Shared interval for all non-critical store flushes, so they batch on the same cadence // Shared interval for all non-critical store flushes, so they batch on the same cadence
const FLUSH_INTERVAL = 3000 const FLUSH_INTERVAL = 3000
@@ -66,7 +65,6 @@ const kinds = {
alert: [ALERT_STATUS, ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID], alert: [ALERT_STATUS, ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID],
space: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS, RELAY_JOIN, RELAY_LEAVE], space: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS, RELAY_JOIN, RELAY_LEAVE],
room: [ room: [
ROOM_ROLES,
ROOM_META, ROOM_META,
ROOM_DELETE, ROOM_DELETE,
ROOM_ADMINS, ROOM_ADMINS,
+1
View File
@@ -31,6 +31,7 @@
<svelte:document onmousemove={onMouseMove} /> <svelte:document onmousemove={onMouseMove} />
<Tippy <Tippy
class="flex"
bind:popover bind:popover
component={EmojiPicker} component={EmojiPicker}
props={{onClick}} props={{onClick}}
+2 -1
View File
@@ -10,6 +10,7 @@
<script lang="ts"> <script lang="ts">
import "emoji-picker-element" import "emoji-picker-element"
import emojiDataUrl from "emoji-picker-element-data/en/emojibase/data.json?url"
import type {Emoji} from "emoji-picker-element/shared" import type {Emoji} from "emoji-picker-element/shared"
import {onMount} from "svelte" import {onMount} from "svelte"
@@ -26,4 +27,4 @@
}) })
</script> </script>
<emoji-picker bind:this={element} class="m-auto"></emoji-picker> <emoji-picker bind:this={element} data-source={emojiDataUrl} class="m-auto"></emoji-picker>
+15 -9
View File
@@ -9,16 +9,22 @@
const {...props}: Props = $props() const {...props}: Props = $props()
</script> </script>
<div class="grid grid-cols-1 gap-2 lg:gap-6 lg:grid-cols-3 {props.class}"> <div class="flex flex-col gap-2 {props.class}">
<label class="flex items-center gap-2 font-bold"> <div class="flex items-center justify-between w-full gap-2">
{@render props.label?.()} {#if props.label}
<label class="flex items-center gap-2 min-w-[30%] max-w-[80%] md:max-w-none">
{@render props.label()}
</label> </label>
<div class="col-span-2 flex items-center gap-2">
{@render props.input?.()}
</div>
<p class="flex-end text-sm opacity-50 lg:col-span-3">
{#if props.info}
{@render props.info?.()}
{/if} {/if}
<div class="flex items-center gap-2 justify-end grow">
{#if props.input}
{@render props.input()}
{/if}
</div>
</div>
{#if props.info}
<p class="text-sm opacity-50">
{@render props.info()}
</p> </p>
{/if}
</div> </div>
+8 -1
View File
@@ -13,7 +13,12 @@
placeholder?: string placeholder?: string
} }
let {value = $bindable(), addLabel, placeholder = "Enter text..."}: Props = $props() let {
value = $bindable(),
addLabel,
placeholder = "Enter text...",
allowAdd = true,
}: Props & {allowAdd?: boolean} = $props()
let draggedIndex: number | null = $state(null) let draggedIndex: number | null = $state(null)
const onChange = (newValue: string[]) => { const onChange = (newValue: string[]) => {
@@ -72,6 +77,7 @@
</div> </div>
</div> </div>
{/each} {/each}
{#if allowAdd}
<Button onclick={addItem} class="btn btn-link w-fit px-0"> <Button onclick={addItem} class="btn btn-link w-fit px-0">
<Icon icon={AddCircle} size={5} /> <Icon icon={AddCircle} size={5} />
{#if addLabel} {#if addLabel}
@@ -80,4 +86,5 @@
Add Item Add Item
{/if} {/if}
</Button> </Button>
{/if}
</div> </div>
+1 -1
View File
@@ -13,7 +13,7 @@
const className = $derived( const className = $derived(
cx( cx(
props.class, props.class,
"scroll-container z-feature flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-y-auto overflow-x-hidden", "scroll-container z-feature flex min-h-0 w-full min-w-0 flex-col overflow-y-auto overflow-x-hidden",
), ),
) )
</script> </script>
+37 -12
View File
@@ -5,6 +5,7 @@
import {Badge} from "@capawesome/capacitor-badge" import {Badge} from "@capawesome/capacitor-badge"
import Bell from "@assets/icons/bell.svg?dataurl" import Bell from "@assets/icons/bell.svg?dataurl"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import FieldInline from "@lib/components/FieldInline.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -63,40 +64,64 @@
<!-- pass --> <!-- pass -->
{:then { isSupported }} {:then { isSupported }}
{#if isSupported} {#if isSupported}
<div class="flex justify-between"> <FieldInline>
{#snippet label()}
<p>Show badge for unread alerts</p> <p>Show badge for unread alerts</p>
{/snippet}
{#snippet input()}
<input type="checkbox" class="toggle toggle-primary" bind:checked={settings.badge} /> <input type="checkbox" class="toggle toggle-primary" bind:checked={settings.badge} />
</div> {/snippet}
</FieldInline>
{/if} {/if}
{/await} {/await}
{#if !Capacitor.isNativePlatform()} {#if !Capacitor.isNativePlatform()}
<div class="flex justify-between"> <FieldInline>
{#snippet label()}
<p>Play sound for new activity</p> <p>Play sound for new activity</p>
{/snippet}
{#snippet input()}
<input type="checkbox" class="toggle toggle-primary" bind:checked={settings.sound} /> <input type="checkbox" class="toggle toggle-primary" bind:checked={settings.sound} />
</div> {/snippet}
</FieldInline>
{/if} {/if}
<div class="flex justify-between"> <FieldInline>
{#snippet label()}
<p>Enable push notifications</p> <p>Enable push notifications</p>
{/snippet}
{#snippet input()}
<input type="checkbox" class="toggle toggle-primary" bind:checked={settings.push} /> <input type="checkbox" class="toggle toggle-primary" bind:checked={settings.push} />
</div> {/snippet}
</FieldInline>
</div> </div>
<div <div
class={cx("card2 bg-alt col-4 shadow-md", { class={cx("card2 bg-alt col-4 shadow-md", {
"pointer-events-none opacity-50": !settings.badge && !settings.sound && !settings.push, "pointer-events-none opacity-50": !settings.badge && !settings.sound && !settings.push,
})}> })}>
<strong class="text-lg">Alert Types</strong> <strong class="text-lg">Alert Types</strong>
<div class="flex justify-between"> <FieldInline>
{#snippet label()}
<p>Notify me about new activity</p> <p>Notify me about new activity</p>
{/snippet}
{#snippet input()}
<input type="checkbox" class="toggle toggle-primary" bind:checked={settings.spaces} /> <input type="checkbox" class="toggle toggle-primary" bind:checked={settings.spaces} />
</div> {/snippet}
<div class="flex justify-between"> </FieldInline>
<FieldInline>
{#snippet label()}
<p>Always notify me when mentioned</p> <p>Always notify me when mentioned</p>
{/snippet}
{#snippet input()}
<input type="checkbox" class="toggle toggle-primary" checked={settings.mentions} /> <input type="checkbox" class="toggle toggle-primary" checked={settings.mentions} />
</div> {/snippet}
<div class="flex justify-between"> </FieldInline>
<FieldInline>
{#snippet label()}
<p>Notify me about new messages</p> <p>Notify me about new messages</p>
{/snippet}
{#snippet input()}
<input type="checkbox" class="toggle toggle-primary" bind:checked={settings.messages} /> <input type="checkbox" class="toggle toggle-primary" bind:checked={settings.messages} />
</div> {/snippet}
</FieldInline>
</div> </div>
<div <div
class="card2 bg-alt sticky -bottom-3 shadow-md flex flex-row items-center justify-between gap-4"> class="card2 bg-alt sticky -bottom-3 shadow-md flex flex-row items-center justify-between gap-4">
+15 -8
View File
@@ -11,6 +11,7 @@
import {Router} from "@welshman/router" import {Router} from "@welshman/router"
import {userMuteList, tagPubkey, publishThunk, userBlossomServerList} from "@welshman/app" import {userMuteList, tagPubkey, publishThunk, userBlossomServerList} from "@welshman/app"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl" import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
@@ -28,6 +29,10 @@
blossomServers = getTagValues("server", getListTags($userBlossomServerList)) blossomServers = getTagValues("server", getListTags($userBlossomServerList))
} }
const addServer = () => {
blossomServers = [...blossomServers, ""]
}
const onsubmit = preventDefault(async () => { const onsubmit = preventDefault(async () => {
await publishSettings($state.snapshot(settings)) await publishSettings($state.snapshot(settings))
@@ -104,7 +109,7 @@
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<input <input
class="range range-primary" class="range range-primary w-full"
type="range" type="range"
min="0.8" min="0.8"
max="1.3" max="1.3"
@@ -115,13 +120,13 @@
</div> </div>
<div class="card2 bg-alt col-4 shadow-md"> <div class="card2 bg-alt col-4 shadow-md">
<strong class="text-lg">Editor Settings</strong> <strong class="text-lg">Editor Settings</strong>
<FieldInline> <Field>
{#snippet label()} {#snippet label()}
<p>Send Delay</p> <p>Send Delay</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<input <input
class="range range-primary" class="range range-primary w-full"
type="range" type="range"
min="0" min="0"
max="10000" max="10000"
@@ -134,17 +139,19 @@
{settings.send_delay === 1000 ? "second" : "seconds"}. {settings.send_delay === 1000 ? "second" : "seconds"}.
</p> </p>
{/snippet} {/snippet}
</FieldInline> </Field>
<Field> <Field>
{#snippet label()} {#snippet label()}
<p>Media Server</p> <p>Media Server</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet secondary()}
<InputList bind:value={blossomServers}> <Button class="link text-sm underline flex items-center gap-1" onclick={addServer}>
{#snippet addLabel()} <Icon icon={AddCircle} size={4} />
Add Server Add Server
</Button>
{/snippet} {/snippet}
</InputList> {#snippet input()}
<InputList allowAdd={false} bind:value={blossomServers} />
{/snippet} {/snippet}
{#snippet info()} {#snippet info()}
<p>Choose a media server type and url for files you upload to {PLATFORM_NAME}.</p> <p>Choose a media server type and url for files you upload to {PLATFORM_NAME}.</p>
+37 -19
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import ShieldMinimalistic from "@assets/icons/shield-minimalistic.svg?dataurl" import ShieldMinimalistic from "@assets/icons/shield-minimalistic.svg?dataurl"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import FieldInline from "@lib/components/FieldInline.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
@@ -11,8 +12,10 @@
settings = {...$userSettingsValues} settings = {...$userSettingsValues}
} }
const onAuthModeChange = (e: any) => { const onAuthModeChange = (e: Event) => {
settings.auth_mode = e.target.checked ? RelayAuthMode.Aggressive : RelayAuthMode.Conservative const target = e.currentTarget as HTMLInputElement
settings.relay_auth = target.checked ? RelayAuthMode.Aggressive : RelayAuthMode.Conservative
} }
const onsubmit = preventDefault(async () => { const onsubmit = preventDefault(async () => {
@@ -30,31 +33,46 @@
<Icon icon={ShieldMinimalistic} /> <Icon icon={ShieldMinimalistic} />
Privacy Settings Privacy Settings
</strong> </strong>
<div class="grid grid-cols-2 gap-2"> <FieldInline>
{#snippet label()}
<p>Authenticate with unknown relays?</p> <p>Authenticate with unknown relays?</p>
{/snippet}
{#snippet input()}
<input <input
type="checkbox" type="checkbox"
class="toggle toggle-primary" class="toggle toggle-primary"
onchange={onAuthModeChange} onchange={onAuthModeChange}
checked={settings.auth_mode === RelayAuthMode.Aggressive} /> checked={settings.relay_auth === RelayAuthMode.Aggressive} />
<p class="col-span-2 text-sm opacity-70"> {/snippet}
Controls whether {PLATFORM_NAME} will identify you to relays not in your lists. {#snippet info()}
</p> <p>Controls whether {PLATFORM_NAME} will identify you to relays not in your lists.</p>
</div> {/snippet}
<div class="grid grid-cols-2 gap-2"> </FieldInline>
<FieldInline>
{#snippet label()}
<p>Report errors?</p> <p>Report errors?</p>
<input type="checkbox" class="toggle toggle-primary" bind:checked={settings.report_errors} /> {/snippet}
<p class="col-span-2 text-sm opacity-70"> {#snippet input()}
Allow {PLATFORM_NAME} to send error reports to help improve the app. <input
</p> type="checkbox"
</div> class="toggle toggle-primary"
<div class="grid grid-cols-2 gap-2"> bind:checked={settings.report_errors} />
{/snippet}
{#snippet info()}
<p>Allow {PLATFORM_NAME} to send error reports to help improve the app.</p>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Report usage?</p> <p>Report usage?</p>
{/snippet}
{#snippet input()}
<input type="checkbox" class="toggle toggle-primary" bind:checked={settings.report_usage} /> <input type="checkbox" class="toggle toggle-primary" bind:checked={settings.report_usage} />
<p class="col-span-2 text-sm opacity-70"> {/snippet}
Allow {PLATFORM_NAME} to collect anonymous usage data. {#snippet info()}
</p> <p>Allow {PLATFORM_NAME} to collect anonymous usage data.</p>
</div> {/snippet}
</FieldInline>
</div> </div>
<div <div
class="card2 bg-alt sticky -bottom-3 shadow-md flex flex-row items-center justify-between gap-4"> class="card2 bg-alt sticky -bottom-3 shadow-md flex flex-row items-center justify-between gap-4">
+13 -1
View File
@@ -27,6 +27,7 @@
import PasswordReset from "@app/components/PasswordReset.svelte" import PasswordReset from "@app/components/PasswordReset.svelte"
import InfoKeys from "@app/components/InfoKeys.svelte" import InfoKeys from "@app/components/InfoKeys.svelte"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {POMADE_NETWORK_ERROR_MESSAGE} from "@app/util/pomadeErrors"
import {clip, pushToast} from "@app/util/toast" import {clip, pushToast} from "@app/util/toast"
const npub = nip19.npubEncode($pubkey!) const npub = nip19.npubEncode($pubkey!)
@@ -48,13 +49,24 @@
const {ok, peersByPrefix} = await Client.requestChallenge($session!.email) const {ok, peersByPrefix} = await Client.requestChallenge($session!.email)
if (!ok) { if (!ok) {
console.error("Pomade challenge request failed during password reset initiation")
pushToast({ pushToast({
theme: "error", theme: "error",
message: "Failed to initiate password reset!", message: POMADE_NETWORK_ERROR_MESSAGE,
}) })
return
} }
pushModal(PasswordReset, {peersByPrefix}) pushModal(PasswordReset, {peersByPrefix})
} catch (error) {
console.error(error)
pushToast({
theme: "error",
message: POMADE_NETWORK_ERROR_MESSAGE,
})
} finally { } finally {
loading = false loading = false
} }
+69 -54
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import Server from "@assets/icons/server.svg?dataurl" import Server from "@assets/icons/server.svg?dataurl"
import CloudCheck from "@assets/icons/cloud-check.svg?dataurl"
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
import ArrowRight from "@assets/icons/arrow-right.svg?dataurl" import ArrowRight from "@assets/icons/arrow-right.svg?dataurl"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -12,78 +14,91 @@
<PageContent class="flex flex-col items-center gap-2 p-2 pt-4"> <PageContent class="flex flex-col items-center gap-2 p-2 pt-4">
<PageHeader> <PageHeader>
{#snippet title()} {#snippet title()}
<div>Create your own Space</div> <div>Choose your Hosting Plan</div>
{/snippet} {/snippet}
{#snippet info()} {#snippet info()}
<p>Get started with one of our trusted partners, or learn how to host your own space.</p> <p>
Select how you want to deploy and manage your new Space. You can always migrate later.
</p>
{/snippet} {/snippet}
</PageHeader> </PageHeader>
<div class="grid w-full max-w-lg grid-cols-1 gap-2 lg:max-w-4xl lg:grid-cols-2"> <div class="flex w-full max-w-lg flex-col gap-4 lg:max-w-4xl">
<div class="card2 bg-alt flex flex-col gap-4"> <div class="grid grid-cols-1 gap-2 lg:grid-cols-2">
<div class="flex flex-col gap-4"> <div class="card2 bg-alt flex flex-col gap-5">
<div class="flex items-center justify-between"> <div class="flex flex-col gap-3">
<div class="flex items-center gap-3"> <div class="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-md">
<Icon icon={Server} /> <Icon icon={CloudCheck} class="text-primary" />
<h3 class="text-lg font-bold">Self-Host your Space</h3>
</div> </div>
<div class="badge badge-neutral">Recommended</div> <div class="flex flex-col gap-1">
<h3 class="text-lg font-bold">Community</h3>
<div class="text-xs font-semibold tracking-wider opacity-60">SELF-HOSTED</div>
</div> </div>
<ul class="flex list-inside list-disc flex-col gap-1 text-sm opacity-70"> <p class="text-sm opacity-70">
<li>Unlimited customization and control</li> For technical users who want full control. Deploy on your own infrastructure and
<li>Free and open source software</li> manage your own updates and scaling.
<li>Full-featured admin dashboards available</li> </p>
<li>Requires some technical skills</li> </div>
<ul class="flex flex-col gap-2 text-sm">
<li class="flex items-center gap-2">
<Icon icon={CheckCircle} class="opacity-60" />
Open source core
</li>
<li class="flex items-center gap-2">
<Icon icon={CheckCircle} class="opacity-60" />
Community support
</li>
<li class="flex items-center gap-2">
<Icon icon={CheckCircle} class="opacity-60" />
Bring your own infra
</li>
</ul> </ul>
</div> <Link
<Link external class="btn btn-primary" href="https://github.com/coracle-social/zooid"> external
Get Started class="btn btn-neutral mt-auto"
href="https://gitea.coracle.social/coracle/zooid">
Get started
<Icon icon={ArrowRight} /> <Icon icon={ArrowRight} />
</Link> </Link>
</div> </div>
<div class="card2 bg-alt flex flex-col gap-4"> <div class="card2 bg-alt border-primary flex flex-col gap-5 border">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-3">
<div class="flex items-center justify-between"> <div class="flex items-start justify-between">
<div class="flex items-center gap-2"> <img alt="Coracle Logo" src="/coracle.png" class="h-10 w-10" />
<img alt="Coracle Logo" src="/coracle.png" class="h-7 w-7" /> <div class="badge badge-primary">Recommended</div>
</div>
<div class="flex flex-col gap-1">
<h3 class="text-lg font-bold">Coracle Hosting</h3> <h3 class="text-lg font-bold">Coracle Hosting</h3>
<div class="text-xs font-semibold tracking-wider opacity-60">FULLY MANAGED</div>
</div> </div>
<div class="badge badge-neutral">Recommended</div> <p class="text-sm opacity-70">
The premium experience. We handle the infrastructure, security updates, and scaling so
you can focus on your community.
</p>
</div> </div>
<ul class="flex list-inside list-disc flex-col gap-1 text-sm opacity-70"> <ul class="flex flex-col gap-2 text-sm">
<li>Simple setup, support included</li> <li class="flex items-center gap-2">
<li>Free and open source software — no vendor lock-in</li> <Icon icon={CheckCircle} class="text-primary" />
<li>Advanced access controls and relay policies</li> One-click deployment
<li>Full-featured admin dashboard</li> </li>
<li class="flex items-center gap-2">
<Icon icon={CheckCircle} class="text-primary" />
Automated backups & scaling
</li>
<li class="flex items-center gap-2">
<Icon icon={CheckCircle} class="text-primary" />
Priority support
</li>
</ul> </ul>
</div> <Link external class="btn btn-primary mt-auto" href="https://hosting.coracle.social">
<Link external class="btn btn-neutral" href="https://hosting.coracle.social"> Start for free
Get Started
<Icon icon={ArrowRight} /> <Icon icon={ArrowRight} />
</Link> </Link>
</div> </div>
<div class="card2 bg-alt flex flex-col gap-4">
<div class="flex flex-col gap-4">
<div class="self-start">
<img
alt="Relay Tools"
src="https://relay.tools/17.svg"
class="-my-20 -ml-2 hidden h-48 dark:block"
style="filter: contrast(50%)" />
<img
alt="Relay Tools"
src="https://relay.tools/19.svg"
class="-my-20 -ml-2 h-48 dark:hidden"
style="filter: contrast(50%)" />
</div> </div>
<ul class="flex list-inside list-disc flex-col gap-1 text-sm opacity-70"> <div class="flex flex-col items-center justify-center gap-2 py-2 text-sm opacity-70">
<li>Independently run</li> <span>Want to host on other servers?</span>
<li>Customizable relay policies</li> <Link external class="link center gap-1" href="https://relay.tools/signup">
<li>Simple management dashboard</li> Other hosting options
<li>Support available</li>
</ul>
</div>
<Link external class="btn btn-neutral" href="https://relay.tools/signup">
Get Started
<Icon icon={ArrowRight} /> <Icon icon={ArrowRight} />
</Link> </Link>
</div> </div>