Compare commits

...

11 Commits

Author SHA1 Message Date
Jon Staab c74f17d931 Fix editing messages with html tags 2026-02-16 08:16:03 -08:00
Jon Staab 8b0645f756 Fix DM media detection 2026-02-13 15:37:17 -08:00
Jon Staab bc84444a99 Make hover target for menu button more reasonable 2026-02-13 15:22:30 -08:00
Jon Staab 22c175e4f7 Watch tracker in feed utils 2026-02-13 15:18:46 -08:00
Jon Staab fdfee9cb75 Revert makeFeed changes 2026-02-13 15:08:12 -08:00
Jon Staab ae6cfa6c6a Clean up report item design, bad/restore user actions, space description input, add feed to home page 2026-02-13 13:05:17 -08:00
Jon Staab e14cff150e Tweak wallet page 2026-02-12 16:35:33 -08:00
Jon Staab 59db6a5c6b Fix makeFeed (maybe) 2026-02-12 16:31:45 -08:00
Jon Staab 2b8190d683 Tweak room detail 2026-02-11 16:44:51 -08:00
Jon Staab e100e56777 Fix scroll to bottom button safe insets 2026-02-11 10:03:50 -08:00
Jon Staab 3b93e06709 Disable wallet on ios 2026-02-10 17:41:32 -08:00
27 changed files with 516 additions and 270 deletions
+2 -2
View File
@@ -358,7 +358,7 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 30;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
@@ -385,7 +385,7 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 30;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
+1 -1
View File
@@ -419,5 +419,5 @@ body.keyboard-open .hide-on-keyboard {
}
.chat__scroll-down {
@apply fixed bottom-28 right-4 z-feature md:bottom-16;
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
}
+5 -4
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
import {isRelayUrl} from "@welshman/util"
import {isRelayUrl, getTagValue} from "@welshman/util"
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/util/modal"
import {dufflepud, PLATFORM_URL} from "@app/core/state"
import {dufflepud, PLATFORM_URL, IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
const {value, event} = $props()
@@ -14,6 +14,7 @@
let hideImage = $state(false)
const url = value.url.toString()
const fileType = getTagValue("file-type", event.tags) || ""
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
@@ -40,11 +41,11 @@
<Link {external} {href} class="my-2 block">
<div class="overflow-hidden rounded-box">
{#if url.match(/\.(mov|webm|mp4)$/)}
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
<video controls src={url} class="max-h-96 rounded-box object-contain object-center">
<track kind="captions" />
</video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/)}
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
</button>
+4 -3
View File
@@ -1,18 +1,19 @@
<script lang="ts">
import {call, displayUrl} from "@welshman/lib"
import {isRelayUrl} from "@welshman/util"
import {isRelayUrl, getTagValue} from "@welshman/util"
import {preventDefault, stopPropagation} from "@lib/html"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import {pushModal} from "@app/util/modal"
import {PLATFORM_URL} from "@app/core/state"
import {PLATFORM_URL, IMAGE_CONTENT_TYPES} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
const {value, event} = $props()
const url = value.url.toString()
const fileType = getTagValue("file-type", event.tags) || ""
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
@@ -23,7 +24,7 @@
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script>
{#if url.match(/\.(jpe?g|png|gif|webp)$/)}
{#if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
<!-- Use a real link so people can copy the href -->
<a
href={url}
+16 -13
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import {Capacitor} from "@capacitor/core"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Server from "@assets/icons/server.svg?dataurl"
import Moon from "@assets/icons/moon.svg?dataurl"
@@ -52,19 +53,21 @@
{/snippet}
</CardButton>
</Link>
<Link replaceState href="/settings/wallet">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Wallet} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Wallet</div>
{/snippet}
{#snippet info()}
<div>Connect a bitcoin wallet for sending social tips</div>
{/snippet}
</CardButton>
</Link>
{#if Capacitor.getPlatform() !== "ios"}
<Link replaceState href="/settings/wallet">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Wallet} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Wallet</div>
{/snippet}
{#snippet info()}
<div>Connect a bitcoin wallet for sending social tips</div>
{/snippet}
</CardButton>
</Link>
{/if}
<Link replaceState href="/settings/relays">
<CardButton class="btn-neutral">
{#snippet icon()}
+8 -5
View File
@@ -2,6 +2,7 @@
import type {Snippet} from "svelte"
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {Router} from "@welshman/router"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
@@ -11,26 +12,28 @@
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
type Props = {
url: string
event: TrustedEvent
children?: Snippet
url?: string
}
const {url, event, children}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const relays = url ? [url] : Router.get().Event(event).getUrls()
const shouldProtect = url ? canEnforceNip70(url) : Promise.resolve(false)
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
publishDelete({relays, event, protect: await shouldProtect})
const createReaction = async (template: EventContent) =>
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
publishReaction({...template, event, relays, protect: await shouldProtect})
const onEmoji = async (emoji: NativeEmoji) =>
publishReaction({
event,
relays,
content: emoji.unicode,
relays: [url],
protect: await shouldProtect,
})
</script>
+35 -7
View File
@@ -15,6 +15,7 @@
import Letter from "@assets/icons/letter-opened.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Restart from "@assets/icons/restart.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte"
@@ -30,7 +31,7 @@
import EventInfo from "@app/components/EventInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte"
import {pubkeyLink, deriveUserIsSpaceAdmin} from "@app/core/state"
import {pubkeyLink, deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {makeChatPath} from "@app/util/routes"
@@ -46,6 +47,10 @@
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const bannedPubkeys = url ? deriveSpaceBannedPubkeyItems(url) : undefined
const isBanned = $derived($bannedPubkeys?.some(item => item.pubkey === pubkey) ?? false)
const back = () => history.back()
const chatPath = makeChatPath([pubkey])
@@ -81,6 +86,20 @@
},
})
const restoreMember = async () => {
const {error} = await manageRelay(url!, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been restored!"})
back()
}
}
let showMenu = $state(false)
onMount(() => {
@@ -112,12 +131,21 @@
</li>
{/if}
{#if $userIsAdmin}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{#if isBanned}
<li>
<Button onclick={restoreMember}>
<Icon icon={Restart} />
Restore User
</Button>
</li>
{:else}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
{/if}
</ul>
</Popover>
+1 -3
View File
@@ -40,9 +40,7 @@
<ModalSubtitle>All reports for this event are shown below.</ModalSubtitle>
</ModalHeader>
{#each $reports.values() as report (report.id)}
<div class="card2 card2-sm bg-alt">
<ReportItem {url} event={report} {onDelete} />
</div>
<ReportItem {url} event={report} {onDelete} />
{/each}
</ModalBody>
<ModalFooter>
+3 -15
View File
@@ -3,14 +3,12 @@
import {getTag, getIdFilters} from "@welshman/util"
import {load, LOCAL_RELAY_URL} from "@welshman/net"
import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import ReportMenu from "@app/components/ReportMenu.svelte"
import {publishDelete, canEnforceNip70} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
import {goToEvent} from "@app/util/routes"
@@ -25,7 +23,6 @@
const etag = getTag("e", event.tags)
const ptag = getTag("p", event.tags)
const reason = etag?.[2] || ptag?.[2]
const shouldProtect = canEnforceNip70(url)
const onClick = (e: Event, event: TrustedEvent) => {
// @ts-ignore
@@ -35,17 +32,12 @@
goToEvent(event)
}
}
const deleteReport = async () => {
publishDelete({event, relays: [url], protect: await shouldProtect})
onDelete?.()
}
</script>
<div class="column gap-4">
<div class="column gap-4 card2 card2-sm bg-alt">
<div class="flex justify-between">
<div>
<Profile pubkey={event.pubkey} {url} avatarSize={5} />
<ProfileName pubkey={event.pubkey} {url} />
<span>
Reported this event
{#if reason}
@@ -53,11 +45,7 @@
{/if}
</span>
</div>
{#if event.pubkey === $pubkey}
<Button class="btn-default btn" onclick={deleteReport}>Delete Report</Button>
{:else}
<ReportMenu {url} {event} />
{/if}
<ReportMenu {url} {event} {onDelete} />
</div>
{#if event.content}
<div class="border-l-2 border-primary pl-3">
+44 -19
View File
@@ -1,26 +1,32 @@
<script lang="ts">
import {getTag, ManagementMethod} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {manageRelay, repository, displayProfileByPubkey} from "@welshman/app"
import {pubkey, manageRelay, repository, displayProfileByPubkey} from "@welshman/app"
import InboxOut from "@assets/icons/inbox-out.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Close from "@assets/icons/close.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Popover from "@lib/components/Popover.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import {deriveUserIsSpaceAdmin} from "@app/core/state"
import {publishDelete, canEnforceNip70} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal"
type Props = {
url: string
event: TrustedEvent
onDelete?: () => void
}
const {url, event}: Props = $props()
const {url, event, onDelete}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const etag = getTag("e", event.tags)
const ptag = getTag("p", event.tags)
@@ -32,6 +38,11 @@
isOpen = false
}
const deleteReport = async () => {
publishDelete({event, relays: [url], protect: await shouldProtect})
onDelete?.()
}
const dismissReport = async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanEvent,
@@ -43,7 +54,7 @@
} else {
pushToast({message: "Content has successfully been deleted!"})
repository.removeEvent(event.id)
history.back()
onDelete?.()
}
}
@@ -51,7 +62,7 @@
const [_, id, reason = ""] = etag!
pushModal(Confirm, {
title: `Delete Content`,
title: `Remove Content`,
message: `Are you sure you want to delete this content from the space?`,
confirm: async () => {
const {error} = await manageRelay(url, {
@@ -63,15 +74,17 @@
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Content has successfully been deleted!"})
repository.removeEvent(event.id)
repository.removeEvent(id)
history.back()
setTimeout(() => onDelete?.())
}
},
})
}
const banMember = () => {
const [pubkey, reason = ""] = ptag!
const [_, pubkey, reason = ""] = ptag!
pushModal(Confirm, {
title: "Ban User",
@@ -86,7 +99,9 @@
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been banned!"})
repository.removeEvent(event.id)
history.back()
setTimeout(() => onDelete?.())
}
},
})
@@ -104,27 +119,37 @@
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<li>
<Button onclick={dismissReport}>
<Icon icon={InboxOut} />
Dismiss Report
</Button>
</li>
{#if etag}
{#if event.pubkey === $pubkey}
<li>
<Button class="text-error" onclick={banContent}>
<Icon icon={TrashBin2} />
Remove Content
<Button onclick={deleteReport}>
<Icon icon={Close} />
Delete Report
</Button>
</li>
{/if}
{#if ptag}
{#if $userIsAdmin}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
<Button onclick={dismissReport}>
<Icon icon={InboxOut} />
Dismiss Report
</Button>
</li>
{#if etag}
<li>
<Button class="text-error" onclick={banContent}>
<Icon icon={TrashBin2} />
Remove Content
</Button>
</li>
{/if}
{#if ptag}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
{/if}
</ul>
</Popover>
+5 -4
View File
@@ -11,6 +11,7 @@
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import EyeClosed from "@assets/icons/eye-closed.svg?dataurl"
import Eye from "@assets/icons/eye.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Lock from "@assets/icons/lock.svg?dataurl"
import Microphone from "@assets/icons/microphone.svg?dataurl"
@@ -198,6 +199,9 @@
{/if}
</div>
</div>
{#if $room?.about}
<p>{$room.about}</p>
{/if}
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
<strong class="text-lg">Room Permissions</strong>
<div class="flex gap-2 flex-wrap">
@@ -233,14 +237,11 @@
<Button
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
data-tip="This room has no additional access controls.">
<Icon size={4} icon={MinusCircle} /> Public
<Icon size={4} icon={Eye} /> Public
</Button>
{/if}
</div>
</div>
{#if $room?.about}
<p>{$room.about}</p>
{/if}
{#if $members.length > 0}
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
+1 -1
View File
@@ -17,7 +17,7 @@
if (popover) {
const {x, y, width, height} = popover.popper.getBoundingClientRect()
if (!between([x, x + width], clientX) || !between([y, y + height + 30], clientY)) {
if (!between([x, x + width], clientX) || !between([y - 50, y + height + 50], clientY)) {
popover.hide()
}
}
+4 -4
View File
@@ -172,12 +172,12 @@
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Description</p>
<p class="flex flex-col items-start h-full">Description</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={values.description} class="grow" type="text" />
</label>
<textarea
bind:value={values.description}
class="min-h-24 textarea textarea-bordered flex w-full"></textarea>
{/snippet}
</FieldInline>
</ModalBody>
+7 -1
View File
@@ -22,6 +22,12 @@
const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}])
const back = () => history.back()
const onDelete = () => {
if ($reports.length === 0) {
back()
}
}
</script>
<Modal>
@@ -32,7 +38,7 @@
</ModalHeader>
<div class="flex flex-col gap-2">
{#each $reports as event (event.id)}
<ReportItem {url} {event} />
<ReportItem {url} {event} {onDelete} />
{:else}
<p class="py-12 text-center">No reports found.</p>
{/each}
+2 -1
View File
@@ -2,6 +2,7 @@
import {PublishStatus} from "@welshman/net"
import {displayRelayUrl} from "@welshman/util"
import Button from "@lib/components/Button.svelte"
import {addPeriod} from "@lib/util"
interface Props {
url: string
@@ -25,7 +26,7 @@
<div class="card2 bg-alt col-2 shadow-lg">
<p>
Failed to publish to {displayRelayUrl(url)}: {message}.
Failed to publish to {displayRelayUrl(url)}: {addPeriod(message)}
</p>
<Button class="link" onclick={retry}>Retry</Button>
</div>
+48 -25
View File
@@ -1,5 +1,6 @@
import {get, writable} from "svelte/store"
import {
call,
uniq,
int,
YEAR,
@@ -80,18 +81,29 @@ export const makeFeed = ({
seen.add(event.id)
}
const unsubscribe = on(repository, "update", ({added, removed}) => {
if (removed.size > 0) {
buffer.update($buffer => $buffer.filter(e => !removed.has(e.id)))
events.update($events => $events.filter(e => !removed.has(e.id)))
}
for (const event of added) {
if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) {
insertEvent(event)
const unsubscribers = [
on(repository, "update", ({added, removed}) => {
if (removed.size > 0) {
buffer.update($buffer => $buffer.filter(e => !removed.has(e.id)))
events.update($events => $events.filter(e => !removed.has(e.id)))
}
}
})
for (const event of added) {
if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) {
insertEvent(event)
}
}
}),
on(tracker, "add", (id: string, trackerUrl: string) => {
if (trackerUrl === url) {
const event = repository.getEvent(id)
if (event && matchFilters(filters, event)) {
insertEvent(event)
}
}
}),
]
const ctrl = makeFeedController({
useWindowing: true,
@@ -122,9 +134,9 @@ export const makeFeed = ({
return {
events,
cleanup: () => {
unsubscribe()
scroller.stop()
controller.abort()
unsubscribers.forEach(call)
},
}
}
@@ -169,17 +181,28 @@ export const makeCalendarFeed = ({
})
}
const unsubscribe = on(repository, "update", ({added, removed}) => {
if (removed.size > 0) {
events.update($events => $events.filter(e => !removed.has(e.id)))
}
for (const event of added) {
if (matchFilters(filters, event)) {
insertEvent(event)
const unsubscribers = [
on(repository, "update", ({added, removed}) => {
if (removed.size > 0) {
events.update($events => $events.filter(e => !removed.has(e.id)))
}
}
})
for (const event of added) {
if (matchFilters(filters, event)) {
insertEvent(event)
}
}
}),
on(tracker, "add", (id: string, trackerUrl: string) => {
if (trackerUrl === url) {
const event = repository.getEvent(id)
if (event && matchFilters(filters, event)) {
insertEvent(event)
}
}
}),
]
const loadTimeframe = (since: number, until: number) => {
const hashes = daysBetween(since, until).map(String)
@@ -234,10 +257,10 @@ export const makeCalendarFeed = ({
return {
events,
cleanup: () => {
backwardScroller.stop()
forwardScroller.stop()
controller.abort()
unsubscribe()
forwardScroller.stop()
backwardScroller.stop()
unsubscribers.forEach(call)
},
}
}
+17 -1
View File
@@ -50,9 +50,12 @@ import {
makeDeriveItem,
deriveItemsByKey,
deriveDeduplicated,
deriveEventsById,
deriveEventsByIdByUrl,
deriveEventsByIdForUrl,
getEventsByIdForUrl,
deriveEventsAsc,
deriveEventsDesc,
} from "@welshman/store"
import {
APP_DATA,
@@ -126,6 +129,10 @@ export const ROOM = "h"
export const PROTECTED = ["-"]
export const IMAGE_CONTENT_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
export const VIDEO_CONTENT_TYPES = ["video/quicktime", "video/webm", "video/mp4"]
export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios"
export const PUSH_SERVER = import.meta.env.VITE_PUSH_SERVER
@@ -229,12 +236,21 @@ export const deriveEvent = makeDeriveEvent({
onDerive: (filters: Filter[], relays: string[]) => load({filters, relays}),
})
export const deriveEvents = (filters: Filter[] = [{}]) =>
deriveEventsDesc(deriveEventsById({repository, filters}))
export const getEventsForUrl = (url: string, filters: Filter[] = [{}]) =>
getEventsByIdForUrl({url, tracker, repository, filters}).values()
export const deriveEventsForUrl = (url: string, filters: Filter[] = [{}]) =>
deriveArray(deriveEventsByIdForUrl({url, tracker, repository, filters}))
export const deriveEventsForUrlAsc = (url: string, filters: Filter[] = [{}]) =>
deriveEventsAsc(deriveEventsByIdForUrl({url, tracker, repository, filters}))
export const deriveEventsForUrlDesc = (url: string, filters: Filter[] = [{}]) =>
deriveEventsDesc(deriveEventsByIdForUrl({url, tracker, repository, filters}))
export const deriveLatestEventForUrl = (url: string, filters: Filter[] = [{}]) =>
deriveDeduplicated(deriveEventsByIdForUrl({url, tracker, repository, filters}), $eventsById =>
first(sortEventsDesc($eventsById.values())),
@@ -463,7 +479,7 @@ export const chatsById = call(() => {
}
}
addEvents(repository.query([{kinds: [DIRECT_MESSAGE, PROFILE]}]))
addEvents(repository.query([{kinds: [...DM_KINDS, PROFILE]}]))
const unsubscribers = [
on(repository, "update", ({added}: RepositoryUpdate) => addEvents(added)),
+2 -1
View File
@@ -15,6 +15,7 @@ import {
} from "@welshman/app"
import type {FileAttributes} from "@welshman/editor"
import {Editor, MentionSuggestion, WelshmanExtension, editorProps} from "@welshman/editor"
import {escapeHtml} from "@lib/html"
import {makeMentionNodeView} from "@app/editor/MentionNodeView"
import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
import {uploadFile} from "@app/core/commands"
@@ -82,7 +83,7 @@ export const makeEditor = async ({
)
return new Editor({
content,
content: escapeHtml(content),
autofocus,
editorProps,
element: document.createElement("div"),
+2 -3
View File
@@ -10,8 +10,6 @@ import {scrollToEvent} from "@lib/html"
import {identity} from "@welshman/lib"
import {
getTagValue,
DIRECT_MESSAGE,
DIRECT_MESSAGE_FILE,
MESSAGE,
THREAD,
CLASSIFIED,
@@ -26,6 +24,7 @@ import {
encodeRelay,
userSpaceUrls,
hasNip29,
DM_KINDS,
ROOM,
} from "@app/core/state"
import {lastPageBySpaceUrl} from "@app/util/history"
@@ -108,7 +107,7 @@ export const goToEvent = async (event: TrustedEvent, options: Record<string, any
}
export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
if (event.kind === DIRECT_MESSAGE || event.kind === DIRECT_MESSAGE_FILE) {
if (DM_KINDS.includes(event.kind)) {
return makeChatPath([event.pubkey, ...getPubkeyTagValues(event.tags)])
}
+7 -5
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import cx from "classnames"
import type {Snippet} from "svelte"
interface Props {
@@ -8,12 +9,13 @@
}
let {children, element = $bindable(), ...props}: Props = $props()
const className = cx(
props.class,
"scroll-container cw cb fixed top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)] z-feature overflow-y-auto overflow-x-hidden",
)
</script>
<div
{...props}
bind:this={element}
data-component="PageContent"
class="scroll-container cw cb fixed top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)] z-feature overflow-y-auto overflow-x-hidden {props.class}">
<div {...props} bind:this={element} data-component="PageContent" class={className}>
{@render children?.()}
</div>
+8
View File
@@ -164,3 +164,11 @@ export const compressFile = async (
})
})
}
export const escapeHtml = (html: string) => {
const element = document.createElement("div")
element.innerText = html
return element.innerHTML
}
+2
View File
@@ -26,3 +26,5 @@ export const buildUrl = (base: string | URL, ...pathname: string[]) => {
return url.toString()
}
export const addPeriod = (s: string) => (s + ".").replace(/\.+$/, ".")
+193 -66
View File
@@ -1,77 +1,204 @@
<script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {shouldUnwrap} from "@welshman/app"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Compass from "@assets/icons/compass.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import {derived, writable} from "svelte/store"
import {batch, call, sortBy, uniqBy} from "@welshman/lib"
import {
NOTE,
MESSAGE,
THREAD,
CLASSIFIED,
ZAP_GOAL,
EVENT_TIME,
COMMENT,
getTagValue,
getTagValues,
getIdAndAddress,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {
makeKindFeed,
makeRelayFeed,
makeScopeFeed,
makeIntersectionFeed,
makeUnionFeed,
Scope,
} from "@welshman/feeds"
import {repository, tracker, makeFeedController, loadUserFollowList} from "@welshman/app"
import History from "@assets/icons/history.svg?dataurl"
import {createScroller} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import CardButton from "@lib/components/CardButton.svelte"
import SpaceAdd from "@app/components/SpaceAdd.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte"
import {pushModal} from "@app/util/modal"
import {goToSpace} from "@app/util/routes"
import {PLATFORM_NAME, PLATFORM_RELAYS} from "@app/core/state"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import NoteItem from "@app/components/NoteItem.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte"
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
import GoalItem from "@app/components/GoalItem.svelte"
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
import RecentConversation from "@app/components/RecentConversation.svelte"
import {makeRoomId, userSpaceUrls, loadUserGroupList, CONTENT_KINDS} from "@app/core/state"
const addSpace = () => pushModal(SpaceAdd)
type Activity = {
type: "message" | "content"
event: TrustedEvent
timestamp: number
count: number
url: string
}
const openChat = () => ($shouldUnwrap ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
const controller = new AbortController()
const events = writable<TrustedEvent[]>([])
const limit = writable(0)
onMount(async () => {
if (PLATFORM_RELAYS.length > 0) {
goToSpace(PLATFORM_RELAYS[0])
const recentActivity = derived([events, limit], ([$events, $limit]) => {
const activity: Activity[] = []
const activityByRoom = new Map<string, Activity>()
const latestActivityByKey = new Map<string, number>()
for (const event of $events.slice(0, $limit)) {
if (event.kind === MESSAGE) {
const h = getTagValue("h", event.tags)
if (!h) continue
for (const url of tracker.getRelays(event.id)) {
const id = makeRoomId(url, h)
const item = activityByRoom.get(id)
if (!item) {
activityByRoom.set(id, {
type: "message",
event,
timestamp: event.created_at,
count: 1,
url,
})
} else if (item.timestamp < event.created_at) {
item.count++
item.event = event
item.timestamp = event.created_at
}
}
} else if (event.kind === COMMENT) {
for (const k of getTagValues(["E", "A"], event.tags)) {
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
}
} else {
for (const k of getIdAndAddress(event)) {
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
}
}
}
for (const item of activityByRoom.values()) {
activity.push(item)
}
for (const [address, timestamp] of latestActivityByKey.entries()) {
const event = repository.getEvent(address)
if (event) {
for (const url of tracker.getRelays(event.id)) {
activity.push({type: "content", event, timestamp, url, count: 1})
break
}
}
}
return sortBy(
a => -a.timestamp,
uniqBy(a => a.event.id, activity),
)
})
let loading = $state(true)
let element: Element | undefined = $state()
onMount(() => {
const promise = call(async () => {
await Promise.all([loadUserGroupList(), loadUserFollowList()])
const ctrl = makeFeedController({
useWindowing: true,
signal: controller.signal,
feed: makeUnionFeed(
makeIntersectionFeed(
makeRelayFeed(...$userSpaceUrls),
makeKindFeed(COMMENT, ...CONTENT_KINDS),
),
makeIntersectionFeed(makeScopeFeed(Scope.Follows), makeKindFeed(NOTE)),
),
onEvent: batch(100, (evts: TrustedEvent[]) => {
events.update($events => [...$events, ...evts])
}),
onExhausted: () => {
loading = false
},
})
const scroller = createScroller({
element: element!,
delay: 800,
threshold: 3000,
onScroll: async () => {
console.log("scroll")
limit.update($limit => {
if ($events.length - $limit < 50) {
ctrl.load(50)
}
return $limit + 10
})
},
})
return () => {
scroller.stop()
controller.abort()
}
})
return () => promise.then(call)
})
</script>
<div class="hero min-h-screen overflow-auto pb-8">
<div class="hero-content">
<div class="column content gap-4">
<h1 class="text-center text-5xl">Welcome to</h1>
<h1 class="mb-4 text-center text-5xl font-bold uppercase">{PLATFORM_NAME}</h1>
<div class="col-3">
<Button onclick={addSpace}>
<CardButton class="btn-neutral">
{#snippet icon()}
<Icon icon={AddCircle} size={7} />
{/snippet}
{#snippet title()}
<div>Add a space</div>
{/snippet}
{#snippet info()}
<div>Use an invite link, or create your own space.</div>
{/snippet}
</CardButton>
</Button>
<Link href="/discover">
<CardButton class="btn-neutral">
{#snippet icon()}
<Icon icon={Compass} size={7} />
{/snippet}
{#snippet title()}
<div>Browse the network</div>
{/snippet}
{#snippet info()}
<div>Find communities on the nostr network.</div>
{/snippet}
</CardButton>
</Link>
<Button onclick={openChat}>
<CardButton class="btn-neutral">
{#snippet icon()}
<Icon icon={ChatRound} size={7} />
{/snippet}
{#snippet title()}
<div>Start a conversation</div>
{/snippet}
{#snippet info()}
<div>Use nostr's encrypted group chats to stay in touch.</div>
{/snippet}
</CardButton>
</Button>
</div>
<PageBar>
{#snippet icon()}
<div class="center">
<Icon icon={History} />
</div>
</div>
</div>
{/snippet}
{#snippet title()}
<strong>Recent Activity</strong>
{/snippet}
{#snippet action()}
<div class="row-2"></div>
{/snippet}
</PageBar>
<PageContent class="flex flex-col gap-2 p-2 pt-4" bind:element>
{#each $recentActivity as { type, event, url, count } (event.id)}
{#if type === "message"}
<RecentConversation {url} {event} {count} />
{:else if event.kind === THREAD}
<ThreadItem {url} {event} />
{:else if event.kind === CLASSIFIED}
<ClassifiedItem {url} {event} />
{:else if event.kind === ZAP_GOAL}
<GoalItem {url} {event} />
{:else if event.kind === EVENT_TIME}
<CalendarEventItem {url} {event} />
{:else}
<NoteItem {url} {event} />
{/if}
{:else}
{#if loading}
<div class="flex justify-center items-center py-20">
<span class="loading loading-spinner mr-3"></span>
Loading recent activity...
</div>
{:else}
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
{/if}
{/each}
</PageContent>
+8 -5
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {Capacitor} from "@capacitor/core"
import {fly} from "@lib/transition"
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import Wallet from "@assets/icons/wallet.svg?dataurl"
@@ -45,11 +46,13 @@
<Icon icon={Bell} /> Alerts
</SecondaryNavItem>
</div>
<div in:fly|local={{delay: 100}}>
<SecondaryNavItem href="/settings/wallet">
<Icon icon={Wallet} /> Wallet
</SecondaryNavItem>
</div>
{#if Capacitor.getPlatform() !== "ios"}
<div in:fly|local={{delay: 100}}>
<SecondaryNavItem href="/settings/wallet">
<Icon icon={Wallet} /> Wallet
</SecondaryNavItem>
</div>
{/if}
<div in:fly|local={{delay: 150}}>
<SecondaryNavItem href="/settings/relays">
<Icon icon={Server} /> Relays
+7 -11
View File
@@ -126,17 +126,13 @@
{/if}
</div>
</div>
<div
class="card2 bg-alt flex flex-col shadow-md"
class:gap-6={profileLightningAddress && walletLud16 && profile?.lud16 !== walletLud16}>
<div class="flex items-center justify-between">
<strong>Lightning Address</strong>
<div class="flex items-center gap-2">
<span class={profileLightningAddress ? "" : "text-warning"}>
{profileLightningAddress ? profileLightningAddress : "Not set"}
</span>
<Button class="btn btn-neutral btn-xs ml-3" onclick={updateReceivingAddress}>Update</Button>
</div>
<div class="card2 bg-alt flex flex-col shadow-md gap-6">
<strong>Lightning Address</strong>
<div class="flex justify-between items-center gap-2">
<span class={profileLightningAddress ? "" : "text-warning"}>
{profileLightningAddress ? profileLightningAddress : "Not set"}
</span>
<Button class="btn btn-neutral btn-xs ml-3" onclick={updateReceivingAddress}>Update</Button>
</div>
{#if profileLightningAddress && walletLud16 && profile?.lud16 !== walletLud16}
<div class="card2 bg-alt flex items-center gap-2 text-xs">
+46 -39
View File
@@ -110,51 +110,58 @@
}
const onSubmit = async ({content, tags}: EventContent) => {
tags.push(["h", h])
try {
tags.push(["h", h])
if (await shouldProtect) {
tags.push(PROTECTED)
}
if (await shouldProtect) {
tags.push(PROTECTED)
}
let template: EventContent & {created_at?: number} = {content, tags}
let template: EventContent & {created_at?: number} = {content, tags}
if (eventToEdit) {
// Delete previous message, to be republished with same timestamp
template.created_at = eventToEdit.created_at
publishDelete({
if (eventToEdit) {
// Don't do anything if message hasn't changed
if (eventToEdit.content === content) {
return
}
// Delete previous message, to be republished with same timestamp
template.created_at = eventToEdit.created_at
publishDelete({
relays: [url],
event: $state.snapshot(eventToEdit),
protect: await shouldProtect,
})
}
if (share) {
template = prependParent(share, template, url)
}
if (parent) {
template = prependParent(parent, template, url)
}
const thunk = publishThunk({
relays: [url],
event: $state.snapshot(eventToEdit),
protect: await shouldProtect,
event: makeEvent(MESSAGE, template),
delay: $userSettingsValues.send_delay,
})
if ($userSettingsValues.send_delay) {
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk},
},
})
}
} finally {
clearParent()
clearShare()
clearEventToEdit()
}
if (share) {
template = prependParent(share, template, url)
}
if (parent) {
template = prependParent(parent, template, url)
}
const thunk = publishThunk({
relays: [url],
event: makeEvent(MESSAGE, template),
delay: $userSettingsValues.send_delay,
})
if ($userSettingsValues.send_delay) {
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk},
},
})
}
clearParent()
clearShare()
clearEventToEdit()
}
const onScroll = () => {
+38 -31
View File
@@ -54,45 +54,52 @@
}
const onSubmit = async ({content, tags}: EventContent) => {
let template: EventContent & {created_at?: number} = {content, tags}
try {
let template: EventContent & {created_at?: number} = {content, tags}
if (eventToEdit) {
// Delete previous message, to be republished with same timestamp
template.created_at = eventToEdit.created_at
publishDelete({relays: [url], event: eventToEdit, protect: await shouldProtect})
}
if (eventToEdit) {
// Don't do anything if message hasn't changed
if (eventToEdit.content === content) {
return
}
if (await shouldProtect) {
tags.push(PROTECTED)
}
// Delete previous message, to be republished with same timestamp
template.created_at = eventToEdit.created_at
publishDelete({relays: [url], event: eventToEdit, protect: await shouldProtect})
}
if (share) {
template = prependParent(share, template, url)
}
if (await shouldProtect) {
tags.push(PROTECTED)
}
if (parent) {
template = prependParent(parent, template, url)
}
if (share) {
template = prependParent(share, template, url)
}
const thunk = publishThunk({
relays: [url],
event: makeEvent(MESSAGE, template),
delay: $userSettingsValues.send_delay,
})
if (parent) {
template = prependParent(parent, template, url)
}
if ($userSettingsValues.send_delay) {
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk},
},
const thunk = publishThunk({
relays: [url],
event: makeEvent(MESSAGE, template),
delay: $userSettingsValues.send_delay,
})
}
clearParent()
clearShare()
clearEventToEdit()
if ($userSettingsValues.send_delay) {
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk},
},
})
}
} finally {
clearParent()
clearShare()
clearEventToEdit()
}
}
const onScroll = () => {