Compare commits

...

19 Commits

Author SHA1 Message Date
Jon Staab cee6c3c164 Bump versions 2025-01-28 19:22:57 -08:00
Jon Staab 06d0ae2798 Trim tiptap css 2025-01-28 16:23:19 -08:00
Jon Staab b129ef4242 Add build hash 2025-01-28 14:51:33 -08:00
Jon Staab 48a45f3a3a Add media server settings 2025-01-28 14:44:43 -08:00
Jon Staab ce1fb396e3 Add button to scroll to new messages in channel 2025-01-28 14:19:46 -08:00
Jon Staab e95c57bcb7 Replace nsec.app signup with njump.me 2025-01-28 13:04:37 -08:00
Jon Staab 414f5a5ace Update changelog 2025-01-28 12:33:35 -08:00
Jon Staab a331d24bb1 Bump welshman 2025-01-28 12:28:26 -08:00
Jon Staab fb53e53411 Add reply to long press menu 2025-01-28 09:47:11 -08:00
Jon Staab 1e7e439e3f Bump version 2025-01-28 09:30:17 -08:00
Jon Staab 3368cba1be Bump welshman/app 2025-01-28 09:26:11 -08:00
Jon Staab 4f0579bb7f Fix missing compose input, handle parents differently 2025-01-28 09:23:51 -08:00
Jon Staab 08e80262a4 Bump welshman, rework channel loading 2025-01-28 08:13:20 -08:00
Jon Staab e10b83bed8 Improve data loading a bit 2025-01-24 13:35:09 -08:00
Jon Staab fa17c398ca Make deploy documentation more clear 2025-01-24 09:49:14 -08:00
Jon Staab e0840f24dd Drop support for legacy messages 2025-01-24 09:36:24 -08:00
Jon Staab 8e38271534 Attempt to fix broken android 2025-01-20 08:40:37 -08:00
Jon Staab 86928fc12c Bump welshman 2025-01-17 15:01:05 -08:00
Jon Staab e15fb3ce9c Update app icon 2025-01-17 09:08:55 -08:00
98 changed files with 4164 additions and 3346 deletions
+11 -1
View File
@@ -1,3 +1,13 @@
src/assets
android
build
.idea
.gradle
*.png
*.ttf
gradlew*
_app
release
android/capacitor-cordova-android-plugins
android/app/src/androidTest
android/app/src/test
+23
View File
@@ -1,5 +1,28 @@
# Changelog
# 0.2.6
* Add reply to long-press menu
* Fix @-mentions
* Replace nsec.app signup with njump.me
* Add new messages button in rooms
* Add media server settings
* Add build hash to about page
# 0.2.5
* Improve room and data loading
* Use @welshman/editor
* Drop support for legacy event kinds
* Add support for back button navigation on android
* Remove note to self page (still available via chat)
* Improve chat conversation search
* Change how reply UI works
# 0.2.4
* Update icons
# 0.2.3
* Add NIP 56 reports for messages and threads
+5 -1
View File
@@ -6,7 +6,11 @@ If you would like to be interoperable with Flotilla, please check out this draft
# Deploy
To run your own Flotilla, it's as simple as `npm run build`, then serve the `build` directory.
To run your own Flotilla, it's as simple as:
- `npm install`
- `npm run build`
- `npx serve build`
## Environment
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "social.flotilla"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 3
versionName "0.2.4"
versionCode 6
versionName "0.2.6"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1
View File
@@ -9,6 +9,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':nostr-signer-capacitor-plugin')
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 531 B

After

Width:  |  Height:  |  Size: 899 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 916 B

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 B

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 B

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 697 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

+1 -3
View File
@@ -1,9 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
@@ -19,4 +17,4 @@
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
</resources>
+3
View File
@@ -2,5 +2,8 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':nostr-signer-capacitor-plugin'
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/nostr-signer-capacitor-plugin/android')
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+11 -1
View File
@@ -14,9 +14,12 @@ fi
# https://stackoverflow.com/a/69127685/1467342
eval "$temp_env"
if [[ -z $VITE_BUILD_HASH ]]; then
export VITE_BUILD_HASH=$(git rev-parse --short HEAD)
fi
if [[ $VITE_PLATFORM_LOGO =~ ^https://* ]]; then
curl $VITE_PLATFORM_LOGO > static/logo.png
cp static/logo.png assets/logo.png
export VITE_PLATFORM_LOGO=static/logo.png
fi
@@ -28,3 +31,10 @@ perl -i -pe"s|{DESCRIPTION}|$VITE_PLATFORM_DESCRIPTION|g" build/index.html
perl -i -pe"s|{ACCENT}|$VITE_PLATFORM_ACCENT|g" build/index.html
perl -i -pe"s|{NAME}|$VITE_PLATFORM_NAME|g" build/index.html
perl -i -pe"s|{URL}|$VITE_PLATFORM_URL|g" build/index.html
npx cap sync
npx @capacitor/assets generate \
--iconBackgroundColor '#eeeeee' \
--iconBackgroundColorDark '#222222' \
--splashBackgroundColor '#ffffff' \
--splashBackgroundColorDark '#191E24'
+3
View File
@@ -4,6 +4,9 @@ const config: CapacitorConfig = {
appId: 'social.flotilla',
appName: 'Flotilla',
webDir: 'build'
server: {
androidScheme: "https"
},
plugins: {
SplashScreen: {
androidSplashResourceName: "splash"
+3515 -2877
View File
File diff suppressed because it is too large Load Diff
+18 -27
View File
@@ -1,18 +1,21 @@
{
"name": "flotilla",
"version": "0.2.4",
"version": "0.2.6",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "./build.sh",
"sourcemaps": "sentry-cli --url https://glitchtip.coracle.social --auth-token $GLITCHTIP_AUTH_TOKEN --api-key $VITE_GLITCHTIP_API_KEY sourcemaps --org coracle --project flotilla --release $(cat package.json|jq -r '.version') upload --url-prefix /_app/immutable/ build/_app/immutable",
"release:android": "cap sync && cap build android --androidreleasetype APK --signing-type apksigner",
"release:android": "cap build android --androidreleasetype APK --signing-type apksigner",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check src && eslint src",
"format": "prettier --write src",
"prepare": "husky"
},
"overrides": {
"@capacitor/core": "^7.0.1"
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@sentry/cli": "^2.40.0",
@@ -37,38 +40,28 @@
},
"type": "module",
"dependencies": {
"@capacitor/android": "^6.1.2",
"@capacitor/cli": "^6.1.2",
"@capacitor/core": "^6.1.2",
"@capacitor/android": "^7.0.1",
"@capacitor/app": "^7.0.0",
"@capacitor/cli": "^6.2.0",
"@capacitor/core": "^7.0.1",
"@noble/curves": "^1.5.0",
"@noble/hashes": "^1.4.0",
"@poppanator/sveltekit-svg": "^4.2.1",
"@sentry/browser": "^8.35.0",
"@sveltejs/adapter-static": "^3.0.4",
"@tiptap/extension-code": "^2.6.6",
"@tiptap/extension-code-block": "^2.6.6",
"@tiptap/extension-document": "^2.6.6",
"@tiptap/extension-dropcursor": "^2.6.6",
"@tiptap/extension-gapcursor": "^2.6.6",
"@tiptap/extension-hard-break": "^2.6.6",
"@tiptap/extension-history": "^2.6.6",
"@tiptap/extension-paragraph": "^2.6.6",
"@tiptap/extension-placeholder": "^2.9.1",
"@tiptap/extension-text": "^2.6.6",
"@tiptap/suggestion": "^2.6.4",
"@types/qrcode": "^1.5.5",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "~0.0.37",
"@welshman/content": "~0.0.15",
"@welshman/dvm": "~0.0.13",
"@welshman/editor": "~0.0.6",
"@welshman/app": "~0.0.41",
"@welshman/content": "~0.0.16",
"@welshman/dvm": "~0.0.14",
"@welshman/editor": "~0.0.10",
"@welshman/feeds": "~0.0.30",
"@welshman/lib": "~0.0.37",
"@welshman/net": "~0.0.45",
"@welshman/signer": "~0.0.19",
"@welshman/lib": "~0.0.38",
"@welshman/net": "~0.0.46",
"@welshman/signer": "~0.0.20",
"@welshman/store": "~0.0.15",
"@welshman/util": "~0.0.57",
"@welshman/util": "~0.0.59",
"daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5",
@@ -76,11 +69,9 @@
"fuse.js": "^7.0.0",
"husky": "^9.1.6",
"idb": "^8.0.0",
"nostr-editor": "^0.0.3",
"nostr-signer-capacitor-plugin": "^0.0.3",
"nostr-tools": "^2.7.2",
"prettier-plugin-tailwindcss": "^0.6.5",
"qrcode": "^1.5.4",
"svelte-tiptap": "^1.1.3"
"qrcode": "^1.5.4"
}
}
+34 -42
View File
@@ -185,45 +185,53 @@
@apply -m-1 min-h-12 p-1;
}
.tiptap[contenteditable="true"] {
.tiptap {
--tiptap-object-bg: var(--base-100);
--tiptap-object-fg: var(--base-content);
--tiptap-active-bg: var(--primary);
--tiptap-active-fg: var(--primary-content);
}
.tiptap-suggestions {
--tiptap-object-bg: var(--base-100);
--tiptap-object-fg: var(--base-content);
--tiptap-active-bg: var(--base-300);
--tiptap-active-fg: var(--base-content);
}
.tiptap {
@apply max-h-[350px] overflow-y-auto p-2 px-4;
}
.chat-editor .tiptap[contenteditable="true"] {
@apply rounded-box bg-base-300;
.tiptap p.is-editor-empty:first-child::before {
opacity: 40%;
}
.input-editor .tiptap[contenteditable="true"] {
@apply input input-bordered h-auto p-[.65rem];
.chat-editor .tiptap {
@apply rounded-box bg-base-300 pr-12;
}
.note-editor .tiptap[contenteditable="true"] {
.note-editor .tiptap {
--tiptap-object-bg: var(--base-200);
@apply input input-bordered h-auto min-h-32 rounded-box p-[.65rem] pb-6;
}
.tiptap pre code {
@apply link-content block w-full;
.input-editor .tiptap {
--tiptap-object-bg: var(--base-200);
@apply input input-bordered h-auto p-[.65rem];
}
.tiptap p code {
@apply link-content;
}
/* link-content, based on tiptap */
.link-content,
.tiptap [tag] {
@apply max-w-full overflow-hidden text-ellipsis whitespace-nowrap rounded bg-neutral px-1 text-neutral-content no-underline;
}
.link-content.link-content-selected {
@apply bg-primary text-primary-content;
}
.tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
opacity: 50%;
.link-content {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 3px;
padding: 0 0.25rem;
background-color: var(--base-100);
color: var(--base-content);
}
/* date input */
@@ -248,19 +256,3 @@ emoji-picker {
--input-font-color: var(--base-content);
--outline-color: var(--base-100);
}
/* tiptap */
.tiptap {
--tiptap-object-bg: var(--base-100);
--tiptap-object-fg: var(--base-content);
--tiptap-active-bg: var(--primary);
--tiptap-active-fg: var(--primary-content);
}
.tiptap-suggestions {
--tiptap-object-bg: var(--base-100);
--tiptap-object-fg: var(--base-content);
--tiptap-active-bg: var(--base-300);
--tiptap-active-fg: var(--base-content);
}
+27 -30
View File
@@ -1,3 +1,4 @@
import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store"
import {ctx, sample, uniq, sleep, chunk, equals} from "@welshman/lib"
import {
@@ -27,8 +28,9 @@ import {
getRelayTags,
isShareableRelayUrl,
getRelayTagValues,
toNostrURI,
} from "@welshman/util"
import type {TrustedEvent, EventTemplate, List} from "@welshman/util"
import type {TrustedEvent, EventContent, EventTemplate, List} from "@welshman/util"
import type {SubscribeRequestWithHandlers} from "@welshman/net"
import {PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer"
@@ -46,7 +48,7 @@ import {
loadFollows,
loadMutes,
tagEvent,
tagReactionTo,
tagEventForReaction,
getRelayUrls,
userRelaySelections,
userInboxRelaySelections,
@@ -55,6 +57,8 @@ import {
addSession,
clearStorage,
dropSession,
tagEventForComment,
tagEventForQuote,
} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import {
@@ -95,6 +99,22 @@ export const getThunkError = async (thunk: Thunk) => {
}
}
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
if (parent) {
const nevent = nip19.neventEncode({
id: parent.id,
kind: parent.kind,
author: parent.pubkey,
relays: ctx.app.router.Event(parent).limit(3).getUrls(),
})
tags = [...tags, tagEventForQuote(parent)]
content = toNostrURI(nevent) + "\n\n" + content
}
return {content, tags}
}
// Log in
export const loginWithNip46 = async ({
@@ -459,7 +479,7 @@ export type ReactionParams = {
}
export const makeReaction = ({event, content}: ReactionParams) => {
const tags = [["k", String(event.kind)], ...tagReactionTo(event)]
const tags = tagEventForReaction(event)
const groupTag = getTag("h", event.tags)
if (groupTag) {
@@ -473,37 +493,14 @@ export const makeReaction = ({event, content}: ReactionParams) => {
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
publishThunk({event: makeReaction(params), relays})
export type ReplyParams = {
export type CommentParams = {
event: TrustedEvent
content: string
tags?: string[][]
}
export const makeComment = ({event, content, tags = []}: ReplyParams) => {
const seenRoots = new Set<string>()
export const makeComment = ({event, content, tags = []}: CommentParams) =>
createEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
for (const [raw, ...tag] of event.tags.filter(t => t[0].match(/^(k|e|a|i)$/i))) {
const T = raw.toUpperCase()
const t = raw.toLowerCase()
if (seenRoots.has(T)) {
tags.push([t, ...tag])
} else {
tags.push([T, ...tag])
seenRoots.add(T)
}
}
if (seenRoots.size === 0) {
tags.push(["K", String(event.kind)])
tags.push(["E", event.id])
}
tags.push(["k", String(event.kind)])
tags.push(["e", event.id])
return createEvent(COMMENT, {content, tags})
}
export const publishComment = ({relays, ...params}: ReplyParams & {relays: string[]}) =>
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
publishThunk({event: makeComment(params), relays})
+7 -7
View File
@@ -1,19 +1,19 @@
<script lang="ts">
import {onMount} from "svelte"
import {writable} from "svelte/store"
import {EditorContent} from "svelte-tiptap"
import {isMobile} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {getEditor} from "@app/editor"
import {makeEditor} from "@app/editor"
export let onSubmit: any
export let content = ""
export let editor: ReturnType<typeof getEditor> | undefined = undefined
export const focus = () => $editor.chain().focus().run()
const uploading = writable(false)
let element: HTMLElement
const uploadFiles = () => $editor!.chain().selectFiles().run()
const submit = () => {
@@ -29,9 +29,9 @@
$editor!.chain().clearContent().run()
}
onMount(() => {
editor = getEditor({autofocus: !isMobile, element, submit, uploading})
const editor = makeEditor({autofocus: !isMobile, submit, uploading, aggressive: true})
onMount(() => {
$editor!.chain().setContent(content).run()
})
</script>
@@ -51,7 +51,7 @@
{/if}
</Button>
<div class="chat-editor flex-grow overflow-hidden">
<div bind:this={element} />
<EditorContent editor={$editor} />
</div>
<Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
@@ -0,0 +1,23 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {displayProfileByPubkey} from "@welshman/app"
import {slide} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
export let event: TrustedEvent
export let clear: () => void
</script>
<div
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
transition:slide>
<p class="text-primary">Replying to @{displayProfileByPubkey(event.pubkey)}</p>
{#key event.id}
<Content {event} hideMedia minLength={100} maxLength={300} expandMode="disabled" />
{/key}
<Button class="absolute right-2 top-2 cursor-pointer" on:click={clear}>
<Icon icon="close-circle" />
</Button>
</div>
+13 -3
View File
@@ -1,12 +1,14 @@
<script lang="ts">
import {hash} from "@welshman/lib"
import {now} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
thunks,
pubkey,
deriveProfile,
deriveProfileDisplay,
formatTimestampAsDate,
formatTimestampAsTime,
pubkey,
} from "@welshman/app"
import {isMobile} from "@lib/html"
import LongPress from "@lib/components/LongPress.svelte"
@@ -31,13 +33,14 @@
export let inert = false
const thunk = $thunks[event.id]
const today = formatTimestampAsDate(now())
const profile = deriveProfile(event.pubkey)
const profileDisplay = deriveProfileDisplay(event.pubkey)
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const reply = () => replyTo(event)
const onLongPress = () => pushModal(ChannelMessageMenuMobile, {url, event})
const onLongPress = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
@@ -70,7 +73,14 @@
<Button on:click={openProfile} class="text-sm font-bold" style="color: {colorValue}">
{$profileDisplay}
</Button>
<span class="text-xs opacity-50">{formatTimestampAsTime(event.created_at)}</span>
<span class="text-xs opacity-50">
{#if formatTimestampAsDate(event.created_at) === today}
Today
{:else}
{formatTimestampAsDate(event.created_at)}
{/if}
at {formatTimestampAsTime(event.created_at)}
</span>
</div>
{/if}
<div class="text-sm">
@@ -11,6 +11,7 @@
export let url
export let event
export let reply
const onEmoji = (emoji: NativeEmoji) => {
history.back()
@@ -19,6 +20,11 @@
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
const sendReply = () => {
history.back()
reply()
}
const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true})
const showDelete = () => pushModal(ConfirmDelete, {url, event})
@@ -29,6 +35,10 @@
<Icon size={4} icon="smile-circle" />
Send Reaction
</Button>
<Button class="btn btn-neutral w-full" on:click={sendReply}>
<Icon size={4} icon="reply" />
Send Reply
</Button>
<Button class="btn btn-neutral" on:click={showInfo}>
<Icon size={4} icon="code-2" />
Message Details
+22 -24
View File
@@ -10,19 +10,10 @@
<script lang="ts">
import {onMount} from "svelte"
import {derived} from "svelte/store"
import type {Readable} from "svelte/store"
import type {Editor} from "svelte-tiptap"
import {nip19} from "nostr-tools"
import {int, nthNe, MINUTE, sortBy, remove, ctx} from "@welshman/lib"
import {int, nthNe, MINUTE, sortBy, remove} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
import {
pubkey,
formatTimestampAsDate,
inboxRelaySelectionsByPubkey,
load,
tagPubkey,
} from "@welshman/app"
import {pubkey, formatTimestampAsDate, inboxRelaySelectionsByPubkey, load} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte"
@@ -36,9 +27,10 @@
import ProfileList from "@app/components/ProfileList.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChannelCompose.svelte"
import ChatComposeParent from "@app/components/ChannelComposeParent.svelte"
import {userSettingValues, deriveChat, splitChatId, PLATFORM_NAME} from "@app/state"
import {pushModal} from "@app/modal"
import {sendWrapped} from "@app/commands"
import {sendWrapped, prependParent} from "@app/commands"
export let id
@@ -57,28 +49,31 @@
pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`})
const replyTo = (event: TrustedEvent) => {
const relays = ctx.app.router.Event(event).getUrls()
const nevent = nip19.neventEncode({...event, relays})
$editor.commands.insertNEvent({nevent})
$editor.commands.insertContent("\n")
$editor.commands.focus()
parent = event
compose.focus()
}
const onSubmit = async ({content, ...params}: EventContent) => {
// Remove p tags since they result in forking the conversation
const tags = [...params.tags.filter(nthNe(0, "p")), ...remove($pubkey!, pubkeys).map(tagPubkey)]
const clearParent = () => {
parent = undefined
}
const onSubmit = async ({content, tags}: EventContent) => {
await sendWrapped({
pubkeys,
template: createEvent(DIRECT_MESSAGE, {content, tags}),
template: createEvent(
DIRECT_MESSAGE,
prependParent(parent, {content, tags: tags.filter(nthNe(0, "p"))}),
),
delay: $userSettingValues.send_delay,
})
clearParent()
}
let loading = true
let editor: Readable<Editor>
let parent: TrustedEvent | undefined
let elements: Element[] = []
let compose: ChatCompose
$: {
elements = []
@@ -200,5 +195,8 @@
<slot name="info" />
</p>
</div>
<ChatCompose bind:editor {onSubmit} />
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} />
{/if}
<ChatCompose bind:this={compose} {onSubmit} />
</div>
+4 -1
View File
@@ -34,7 +34,10 @@
<div class="flex flex-col justify-start gap-1">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
{#if others.length === 1}
{#if others.length === 0}
<ProfileCircle pubkey={$pubkey} size={5} />
Note to self
{:else if others.length === 1}
<ProfileCircle pubkey={others[0]} size={5} />
<ProfileName pubkey={others[0]} />
{:else}
+2 -1
View File
@@ -112,7 +112,8 @@
</div>
</Button>
{/if}
<span class="text-xs opacity-50">{formatTimestampAsTime(event.created_at)}</span>
<span class="whitespace-nowrap text-xs opacity-50"
>{formatTimestampAsTime(event.created_at)}</span>
</div>
{/if}
<div class="text-sm">
+1 -1
View File
@@ -93,7 +93,7 @@
mediaLength: hideMedia ? 20 : 200,
})
$: hasEllipsis = shortContent.find(isEllipsis)
$: hasEllipsis = shortContent.some(isEllipsis)
$: expandInline = hasEllipsis && expandMode === "inline"
$: expandBlock = hasEllipsis && expandMode === "block"
</script>
+1 -1
View File
@@ -30,7 +30,7 @@
</video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/)}
<button type="button" on:click|stopPropagation|preventDefault={expand}>
<img alt="Link preview" src={imgproxy(url)} class="m-auto max-h-96" />
<img alt="Link preview" src={imgproxy(url)} class="m-auto max-h-96 rounded-box" />
</button>
{:else}
{#await loadPreview()}
+5 -9
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import {onMount} from "svelte"
import {EditorContent} from "svelte-tiptap"
import {writable} from "svelte/store"
import {randomId} from "@welshman/lib"
import {createEvent, EVENT_TIME} from "@welshman/util"
@@ -11,7 +11,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import {PROTECTED} from "@app/state"
import {getEditor} from "@app/editor"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
export let url
@@ -54,16 +54,12 @@
history.back()
}
let element: HTMLElement
let editor: ReturnType<typeof getEditor>
const editor = makeEditor({submit, uploading})
let title = ""
let location = ""
let start: Date
let end: Date
onMount(() => {
editor = getEditor({submit, element, uploading})
})
</script>
<form class="column gap-4" on:submit|preventDefault={submit}>
@@ -83,7 +79,7 @@
slot="input"
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
<div class="input-editor flex-grow overflow-hidden">
<div bind:this={element} />
<EditorContent editor={$editor} />
</div>
<Button
data-tip="Add an image"
+1 -1
View File
@@ -26,7 +26,7 @@
const back = () => history.back()
const onSubmit = async () => {
const {signerPubkey, connectSecret, relays} = broker.parseBunkerUrl(input)
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(input)
if (loading) {
return
+4 -8
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import {onMount} from "svelte"
import {first, sortBy, ctx} from "@welshman/lib"
import {getAncestorTags} from "@welshman/util"
import {ctx} from "@welshman/lib"
import type {Filter} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {repository, load, loadRelaySelections, formatTimestampRelative} from "@welshman/app"
@@ -13,11 +12,9 @@
export let pubkey
const filters: Filter[] = [{authors: [pubkey]}]
const filters: Filter[] = [{authors: [pubkey], limit: 1}]
const events = deriveEvents(repository, {filters})
$: roots = $events.filter(e => getAncestorTags(e.tags).replies.length === 0)
onMount(async () => {
// Make sure we have their relay selections before we load their posts
await loadRelaySelections(pubkey)
@@ -39,10 +36,9 @@
</Link>
</div>
<ProfileInfo {pubkey} />
{#if roots.length > 0}
{@const event = first(sortBy(e => -e.created_at, roots))}
{#if $events.length > 0}
<div class="bg-alt badge badge-neutral border-none">
Last active {formatTimestampRelative(event.created_at)}
Last active {formatTimestampRelative($events[0].created_at)}
</div>
{/if}
<Link class="btn btn-primary sm:hidden" href={makeChatPath([pubkey])}>
+2 -2
View File
@@ -2,7 +2,7 @@
import {onMount} from "svelte"
import {sortBy, uniqBy} from "@welshman/lib"
import {feedFromFilter, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds"
import {NOTE, getAncestorTags} from "@welshman/util"
import {NOTE, getReplyTags} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {createFeedController} from "@welshman/app"
import {createScroller} from "@lib/html"
@@ -22,7 +22,7 @@
feedFromFilter({kinds: [NOTE], authors: [pubkey]}),
),
onEvent: (event: TrustedEvent) => {
if (getAncestorTags(event.tags).replies.length === 0) {
if (getReplyTags(event.tags).replies.length === 0) {
buffer.push(event)
}
},
+12 -10
View File
@@ -36,16 +36,18 @@
)
onMount(() => {
load({
relays: [url],
filters: [{kinds: [REACTION, REPORT, DELETE], "#e": [event.id]}],
onEvent: batch(300, (events: TrustedEvent[]) => {
load({
relays: [url],
filters: [{kinds: [DELETE], "#e": events.map(e => e.id)}],
})
}),
})
if (url) {
load({
relays: [url],
filters: [{kinds: [REACTION, REPORT, DELETE], "#e": [event.id]}],
onEvent: batch(300, (events: TrustedEvent[]) => {
load({
relays: [url],
filters: [{kinds: [DELETE], "#e": events.map(e => e.id)}],
})
}),
})
}
})
</script>
+23 -97
View File
@@ -1,107 +1,50 @@
<script lang="ts">
import {postJson, assoc} from "@welshman/lib"
import {makeSecret, Nip46Broker} from "@welshman/signer"
import {pubkey, loadHandle, updateSession} from "@welshman/app"
import {postJson} from "@welshman/lib"
import {isMobile} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Divider from "@lib/components/Divider.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import LogIn from "@app/components/LogIn.svelte"
import InfoNostr from "@app/components/InfoNostr.svelte"
import SignUpSuccess from "@app/components/SignUpSuccess.svelte"
import {pushModal, clearModals} from "@app/modal"
import {setChecked} from "@app/notifications"
import {BURROW_URL, PLATFORM_NAME, NIP46_PERMS} from "@app/state"
import {pushModal} from "@app/modal"
import {BURROW_URL, PLATFORM_NAME} from "@app/state"
import {pushToast} from "@app/toast"
import {loginWithNip46} from "@app/commands"
const relays = ["wss://relay.nsec.app"]
const ac = window.location.origin
const signerDomain = "nsec.app"
const at = isMobile ? "android" : "web"
const signerPubkey = "e24a86943d37a91ab485d6f9a7c66097c25ddd67e8bd1b75ed252a3c266cf9bb"
const nstart = `https://start.njump.me/?an=Flotilla&at=${at}&ac=${ac}`
const login = () => pushModal(LogIn)
const withLoading =
(cb: (...args: any[]) => any) =>
async (...args: any[]) => {
loading = true
const signupPassword = async () => {
loading = true
try {
await cb(...args)
} finally {
loading = false
try {
const res = await postJson(BURROW_URL + "/user", {email, password})
if (res.error) {
pushToast({message: res.error, theme: "error"})
} else {
pushModal(SignUpSuccess, {email}, {replaceState: true})
}
} finally {
loading = false
}
const signupPassword = withLoading(async () => {
const res = await postJson(BURROW_URL + "/user", {email, password})
if (res.error) {
return pushToast({message: res.error, theme: "error"})
}
pushModal(SignUpSuccess, {email}, {replaceState: true})
})
const signupNsecApp = withLoading(async () => {
const handle = await loadHandle(`${username}@${signerDomain}`)
if (handle?.pubkey) {
return pushToast({
theme: "error",
message: "Sorry, it looks like that account already exists. Try logging in instead.",
})
}
const clientSecret = makeSecret()
const broker = Nip46Broker.get({
relays,
clientSecret,
signerPubkey,
algorithm: "nip04",
})
const userPubkey = await broker.createAccount(username, signerDomain, NIP46_PERMS)
if (!userPubkey) {
return pushToast({
theme: "error",
message: "Sorry, it looks like something went wrong. Please try again.",
})
}
// Now we can log in. Use the user's pubkey for the handler (legacy stuff)
const success = await loginWithNip46({relays, signerPubkey: userPubkey, clientSecret})
if (!success) {
return pushToast({
theme: "error",
message: "Sorry, it looks like something went wrong. Please try again.",
})
}
updateSession($pubkey!, assoc("email", email))
pushToast({message: "Successfully logged in!"})
setChecked("*")
clearModals()
})
}
const signup = () => {
if (BURROW_URL) {
signupPassword()
} else {
signupNsecApp()
}
}
let email = ""
let password = ""
let username = ""
let loading = false
</script>
@@ -136,29 +79,12 @@
on other nostr applications, you can create a nostr key yourself, or export your key from {PLATFORM_NAME}
later.
</p>
{:else}
<Field>
<div class="flex items-center gap-2" slot="input">
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="user-rounded" />
<input bind:value={username} class="grow" type="text" placeholder="username" />
</label>
@{signerDomain}
</div>
</Field>
<Button type="submit" class="btn btn-primary" disabled={loading || !username}>
<Spinner {loading}>Sign Up</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
<Divider>Or</Divider>
{/if}
<Divider>Or</Divider>
<Link
external
href="https://nosta.me"
class="btn {username || email || password ? 'btn-neutral' : 'btn-primary'}">
<a href={nstart} class="btn {email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="square-share-line" />
Get started on Nosta.me
</Link>
Get started on njump
</a>
<div class="text-sm">
Already have an account?
<Button class="link" on:click={login}>Log in instead</Button>
+5 -9
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {onMount} from "svelte"
import {writable} from "svelte/store"
import {EditorContent} from "svelte-tiptap"
import {createEvent, THREAD} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {isMobile} from "@lib/html"
@@ -11,7 +11,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/toast"
import {GENERAL, tagRoom, PROTECTED} from "@app/state"
import {getEditor} from "@app/editor"
import {makeEditor} from "@app/editor"
export let url
@@ -53,13 +53,9 @@
history.back()
}
let title: string
let element: HTMLElement
let editor: ReturnType<typeof getEditor>
const editor = makeEditor({submit, uploading, placeholder: "What's on your mind?"})
onMount(() => {
editor = getEditor({submit, element, uploading, placeholder: "What's on your mind?"})
})
let title: string
</script>
<form class="column gap-4" on:submit|preventDefault={submit}>
@@ -83,7 +79,7 @@
<Field>
<p slot="label">Message*</p>
<div slot="input" class="note-editor flex-grow overflow-hidden">
<div bind:this={element} />
<EditorContent editor={$editor} />
</div>
</Field>
<Button
+4 -9
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {onMount} from "svelte"
import {writable} from "svelte/store"
import {EditorContent} from "svelte-tiptap"
import {isMobile} from "@lib/html"
import {fly, slideAndFade} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
@@ -8,7 +8,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {publishComment} from "@app/commands"
import {tagRoom, GENERAL, PROTECTED} from "@app/state"
import {getEditor} from "@app/editor"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
export let url
@@ -34,12 +34,7 @@
onSubmit(publishComment({event, content, tags, relays: [url]}))
}
let editor: ReturnType<typeof getEditor>
let element: HTMLElement
onMount(() => {
editor = getEditor({element, submit, uploading, autofocus: !isMobile})
})
const editor = makeEditor({submit, uploading, autofocus: !isMobile})
</script>
<form
@@ -49,7 +44,7 @@
class="card2 sticky bottom-2 z-feature mx-2 mt-4 bg-neutral">
<div class="relative">
<div class="note-editor flex-grow overflow-hidden">
<div bind:this={element} />
<EditorContent editor={$editor} />
</div>
<Button
data-tip="Add an image"
+8 -4
View File
@@ -25,27 +25,26 @@ export const signWithAssert = async (template: StampedEvent) => {
return event!
}
export const getEditor = ({
export const makeEditor = ({
aggressive = false,
autofocus = false,
charCount,
content = "",
element,
placeholder = "",
submit,
uploading,
wordCount,
}: {
aggressive?: boolean
autofocus?: boolean
charCount?: Writable<number>
content?: string
element: HTMLElement
placeholder?: string
submit: () => void
uploading?: Writable<boolean>
wordCount?: Writable<number>
}) =>
createEditor({
element,
content,
autofocus,
extensions: [
@@ -60,6 +59,11 @@ export const getEditor = ({
placeholder,
},
},
breakOrSubmit: {
config: {
aggressive,
},
},
fileUpload: {
config: {
onDrop() {
+6 -10
View File
@@ -3,16 +3,9 @@ import {synced, throttled} from "@welshman/store"
import {pubkey} from "@welshman/app"
import {prop, spec, identity, now, groupBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {MESSAGE, COMMENT, getTagValue} from "@welshman/util"
import {MESSAGE, THREAD, COMMENT, getTagValue} from "@welshman/util"
import {makeSpacePath, makeChatPath, makeThreadPath, makeRoomPath} from "@app/routes"
import {
THREAD_FILTER,
COMMENT_FILTER,
chats,
getUrlsForEvent,
userRoomsByUrl,
repositoryStore,
} from "@app/state"
import {chats, getUrlsForEvent, userRoomsByUrl, repositoryStore} from "@app/state"
// Checked state
@@ -60,7 +53,10 @@ export const notifications = derived(
}
}
const allThreadEvents = $repository.query([THREAD_FILTER, COMMENT_FILTER])
const allThreadEvents = $repository.query([
{kinds: [THREAD]},
{kinds: [COMMENT], "#K": [String(THREAD)]},
])
const allMessageEvents = $repository.query([{kinds: [MESSAGE]}])
for (const [url, rooms] of $userRoomsByUrl.entries()) {
+83 -4
View File
@@ -1,9 +1,12 @@
import {get} from "svelte/store"
import {partition, assoc, now} from "@welshman/lib"
import {MESSAGE, THREAD, COMMENT} from "@welshman/util"
import {get, writable} from "svelte/store"
import {partition, insert, sortBy, assoc, now} from "@welshman/lib"
import {MESSAGE, DELETE, THREAD, COMMENT, matchFilters, getTagValues} from "@welshman/util"
import type {TrustedEvent, Filter} from "@welshman/util"
import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds"
import type {Subscription} from "@welshman/net"
import type {AppSyncOpts} from "@welshman/app"
import {subscribe, load, repository, pull, hasNegentropy} from "@welshman/app"
import {subscribe, load, repository, pull, hasNegentropy, createFeedController} from "@welshman/app"
import {createScroller} from "@lib/html"
import {userRoomsByUrl, getUrlsForEvent} from "@app/state"
// Utils
@@ -29,6 +32,82 @@ export const pullConservatively = ({relays, filters}: AppSyncOpts) => {
return Promise.all(promises)
}
export const makeFeed = ({
relays,
feedFilters,
subscriptionFilters,
element,
onExhausted,
initialEvents = [],
}: {
relays: string[]
feedFilters: Filter[]
subscriptionFilters: Filter[]
element: HTMLElement
onExhausted?: () => void
initialEvents?: TrustedEvent[]
}) => {
const buffer = writable<TrustedEvent[]>([])
const events = writable(initialEvents)
const onEvent = (event: TrustedEvent) => {
buffer.update($buffer => {
for (let i = 0; i < $buffer.length; i++) {
if ($buffer[i].id === event.id) return $buffer
if ($buffer[i].created_at < event.created_at) return insert(i, event, $buffer)
}
return [...$buffer, event]
})
}
const deleteEvent = (e: TrustedEvent) => {
const ids = getTagValues(["e", "a"], e.tags)
buffer.update($buffer => $buffer.filter(e => !ids.includes(e.id)))
events.update($events => $events.filter(e => !ids.includes(e.id)))
}
const ctrl = createFeedController({
useWindowing: true,
feed: makeIntersectionFeed(makeRelayFeed(...relays), feedFromFilters(feedFilters)),
onEvent,
onExhausted,
})
const sub = subscribe({
relays,
filters: subscriptionFilters,
onEvent: (e: TrustedEvent) => {
if (matchFilters(feedFilters, e)) onEvent(e)
if (e.kind === DELETE) deleteEvent(e)
},
})
const scroller = createScroller({
element,
delay: 300,
threshold: 10_000,
onScroll: async () => {
const $buffer = get(buffer)
events.update($events => sortBy(e => -e.created_at, [...$events, ...$buffer.splice(0, 100)]))
if ($buffer.length < 100) {
ctrl.load(100)
}
},
})
return {
events,
cleanup: () => {
scroller.stop()
sub.close()
},
}
}
// Application requests
export const listenForNotifications = () => {
+13 -36
View File
@@ -80,10 +80,6 @@ export const GENERAL = "_"
export const PROTECTED = ["-"]
export const LEGACY_MESSAGE = 209
export const LEGACY_THREAD = 309
export const INDEXER_RELAYS = [
"wss://purplepag.es/",
"wss://relay.damus.io/",
@@ -118,13 +114,6 @@ export const IMGPROXY_URL = "https://imgproxy.coracle.social"
export const REACTION_KINDS = [REACTION, ZAP_RESPONSE]
export const THREAD_FILTER: Filter = {kinds: [THREAD, LEGACY_THREAD]}
export const COMMENT_FILTER: Filter = {
kinds: [COMMENT],
"#K": [String(THREAD), String(LEGACY_THREAD)],
}
export const NIP46_PERMS =
"nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt," +
[CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, GROUPS, WRAP, REACTION]
@@ -421,19 +410,22 @@ export const chats = derived(
pushToMapKey(messagesByChatId, chatId, message)
}
const displayPubkey = (pubkey: string) => {
const profile = $profilesByPubkey.get(pubkey)
return profile ? displayProfile(profile) : ""
}
return sortBy(
c => -c.last_activity,
Array.from(messagesByChatId.entries()).map(([id, events]): Chat => {
const pubkeys = splitChatId(id)
const pubkeys = remove($pubkey!, splitChatId(id))
const messages = sortBy(e => -e.created_at, events)
const last_activity = messages[0].created_at
const search_text = remove($pubkey as string, pubkeys)
.map(pubkey => {
const profile = $profilesByPubkey.get(pubkey)
return profile ? displayProfile(profile) : ""
})
.join(" ")
const search_text =
pubkeys.length === 0
? displayPubkey($pubkey!) + " note to self"
: pubkeys.map(displayPubkey).join(" ")
return {id, pubkeys, messages, last_activity, search_text}
}),
@@ -460,24 +452,9 @@ export const chatSearch = derived(chats, $chats =>
// Messages
// TODO: remove support for legacy messages
export const adaptLegacyMessage = (event: TrustedEvent) => {
if (event.kind === LEGACY_MESSAGE) {
let room = event.tags.find(nthEq(0, "~"))?.[1] || GENERAL
if (room === "general") {
room = GENERAL
}
return {...event, kind: MESSAGE, tags: [...event.tags, tagRoom(room, "")]}
}
return event
}
export const messages = derived(
deriveEvents(repository, {filters: [{kinds: [MESSAGE, LEGACY_MESSAGE]}]}),
$events => $events.map(adaptLegacyMessage),
deriveEvents(repository, {filters: [{kinds: [MESSAGE]}]}),
$events => $events,
)
// Nip29
+2 -2
View File
@@ -9,7 +9,7 @@
import Tippy from "@lib/components/Tippy.svelte"
export let value: string
export let options: string[]
export let options: string[] = []
export let allowCreate = false
let input: Element
@@ -20,7 +20,7 @@
createSearch(options, {
getValue: identity,
fuseOptions: {keys: [""]},
}),
}).searchValues,
)
const select = (newValue: string) => {
+13
View File
@@ -1,3 +1,6 @@
import {hexToBytes, bytesToHex} from "@noble/hashes/utils"
import * as nip19 from "nostr-tools/nip19"
export const displayList = <T>(xs: T[], conj = "and", n = 6, locale = "en-US") => {
const stringItems = xs.map(String)
@@ -11,3 +14,13 @@ export const displayList = <T>(xs: T[], conj = "and", n = 6, locale = "en-US") =
return new Intl.ListFormat(locale, {style: "long", type: "conjunction"}).format(stringItems)
}
export const nsecEncode = (secret: string) => nip19.nsecEncode(hexToBytes(secret))
export const nsecDecode = (nsec: string) => {
const {type, data} = nip19.decode(nsec)
if (type !== "nsec") throw new Error(`Invalid nsec: ${nsec}`)
return bytesToHex(data)
}
+43 -2
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import "@src/app.css"
import {onMount} from "svelte"
import {nip19} from "nostr-tools"
import * as nip19 from "nostr-tools/nip19"
import {get, derived} from "svelte/store"
import {App} from "@capacitor/app"
import {dev} from "$app/environment"
import {goto} from "$app/navigation"
import {bytesToHex, hexToBytes} from "@noble/hashes/utils"
import {identity, sleep, take, sortBy, ago, now, HOUR, WEEK, MONTH, Worker} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
@@ -20,6 +22,7 @@
getPubkeyTagValues,
getListTags,
} from "@welshman/util"
import {Nip46Broker, getPubkey, makeSecret} from "@welshman/signer"
import {
relays,
handles,
@@ -38,6 +41,7 @@
getRelayUrls,
subscribe,
userInboxRelaySelections,
addSession,
} from "@welshman/app"
import * as lib from "@welshman/lib"
import * as util from "@welshman/util"
@@ -48,9 +52,10 @@
import ModalContainer from "@app/components/ModalContainer.svelte"
import {setupTracking} from "@app/tracking"
import {setupAnalytics} from "@app/analytics"
import {nsecDecode} from "@lib/util"
import {theme} from "@app/theme"
import {INDEXER_RELAYS, userMembership, ensureUnwrapped, canDecrypt} from "@app/state"
import {loadUserData} from "@app/commands"
import {loadUserData, loginWithNip46} from "@app/commands"
import {listenForNotifications} from "@app/requests"
import * as commands from "@app/commands"
import * as requests from "@app/requests"
@@ -81,10 +86,46 @@
...notifications,
})
// Nstart login
if (window.location.hash?.startsWith("#nostr-login")) {
const params = new URLSearchParams(window.location.hash.slice(1))
const login = params.get("nostr-login")
let success = false
try {
if (login?.startsWith("bunker://")) {
success = await loginWithNip46({
clientSecret: makeSecret(),
...Nip46Broker.parseBunkerUrl(login),
})
} else if (login) {
const secret = nsecDecode(login)
addSession({method: "nip01", secret, pubkey: getPubkey(secret)})
success = true
}
} catch (e) {
console.error(e)
}
if (success) {
goto("/home")
}
}
if (!db) {
setupTracking()
setupAnalytics()
App.addListener("backButton", () => {
if (window.history.length > 1) {
window.history.back()
} else {
App.exitApp()
}
})
ready = initStorage("flotilla", 5, {
relays: storageAdapters.fromCollectionStore("url", relays, {throttle: 3000}),
handles: storageAdapters.fromCollectionStore("nip05", handles, {throttle: 3000}),
+1 -1
View File
@@ -25,7 +25,7 @@
let term = ""
$: chats = $chatSearch.searchOptions(term).filter(c => c.pubkeys.length > 1)
$: chats = $chatSearch.searchOptions(term)
</script>
<SecondaryNav>
+1 -1
View File
@@ -17,7 +17,7 @@
const openMenu = () => pushModal(ChatMenuMobile)
$: chats = $chatSearch.searchOptions(term).filter(c => c.pubkeys.length > 1)
$: chats = $chatSearch.searchOptions(term)
onDestroy(() => {
setChecked($page.url.pathname)
+19
View File
@@ -4,6 +4,7 @@
import {pubkey, signer, userMutes, tagPubkey, publishThunk} from "@welshman/app"
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {pushToast} from "@app/toast"
@@ -79,6 +80,24 @@
{settings.send_delay === 1000 ? "second" : "seconds"}.
</p>
</FieldInline>
<Field>
<p slot="label">Media Server</p>
<div slot="input" class="flex gap-2">
<select bind:value={settings.upload_type} class="select select-bordered">
<option value="nip96">NIP 96 (default)</option>
<option value="blossom">Blossom</option>
</select>
<label class="input input-bordered flex flex-grow items-center gap-2">
<Icon icon="link-round" />
{#if settings.upload_type === "nip96"}
<input class="grow" bind:value={settings.nip96_urls[0]} />
{:else}
<input class="grow" bind:value={settings.blossom_urls[0]} />
{/if}
</label>
</div>
<p slot="info">Choose a media server type and url for files you upload to flotilla.</p>
</Field>
<div class="mt-4 flex flex-row items-center justify-between gap-4">
<Button class="btn btn-neutral" on:click={reset}>Discard Changes</Button>
<Button type="submit" class="btn btn-primary">Save Changes</Button>
+5
View File
@@ -6,6 +6,8 @@
import {PLATFORM_NAME} from "@app/state"
import {pushModal} from "@app/modal"
const hash = import.meta.env.VITE_BUILD_HASH
const pubkey = "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"
const openProfile = () => pushModal(ProfileDetail, {pubkey})
@@ -48,6 +50,9 @@
class="link"
href="https://www.figma.com/community/file/1166831539721848736">480 Design</Link>
</p>
{#if hash}
<p class="text-xs">Running build {hash.slice(0, 8)}</p>
{/if}
</div>
<div class="flex justify-center gap-4">
<div class="tooltip" data-tip="Source Code">
+12 -13
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import {onMount} from "svelte"
import {page} from "$app/stores"
import {ago, WEEK} from "@welshman/lib"
import {GROUPS, MESSAGE, DELETE} from "@welshman/util"
import {subscribe} from "@welshman/app"
import {ago, MONTH} from "@welshman/lib"
import {GROUPS, THREAD, COMMENT, MESSAGE, DELETE} from "@welshman/util"
import {subscribe, load} from "@welshman/app"
import Page from "@lib/components/Page.svelte"
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
import MenuSpace from "@app/components/MenuSpace.svelte"
@@ -12,7 +12,7 @@
import {pushModal} from "@app/modal"
import {setChecked} from "@app/notifications"
import {checkRelayConnection, checkRelayAuth, checkRelayAccess} from "@app/commands"
import {decodeRelay, userRoomsByUrl, THREAD_FILTER, COMMENT_FILTER} from "@app/state"
import {decodeRelay, userRoomsByUrl} from "@app/state"
import {pullConservatively} from "@app/requests"
import {notifications} from "@app/notifications"
@@ -47,23 +47,22 @@
checkConnection()
const relays = [url]
const since = ago(WEEK)
const since = ago(MONTH)
// Load all groups for this space to populate navigation
pullConservatively({relays, filters: [{kinds: [GROUPS]}]})
// Load all groups for this space to populate navigation. It would be nice to sync, but relay29
// is too picky about how requests are built.
load({relays, filters: [{kinds: [GROUPS]}], delay: 0})
// Load threads and comments
// Load threads, comments, and recent messages for user rooms to help with a quick page transition
pullConservatively({
relays,
filters: [
{...THREAD_FILTER, since},
{...COMMENT_FILTER, since},
{kinds: [THREAD], since},
{kinds: [COMMENT], "#K": [String(THREAD)], since},
...rooms.map(r => ({kinds: [MESSAGE], "#h": [r], since})),
],
})
// Load recent messages for user rooms to help with a quick page transition
pullConservatively({relays, filters: rooms.map(r => ({kinds: [MESSAGE], "#h": [r], since}))})
// Listen for deletes that would apply to messages we already have, and new groups
const sub = subscribe({relays, filters: [{kinds: [DELETE, GROUPS], since}]})
+158 -101
View File
@@ -1,70 +1,50 @@
<script lang="ts">
import {nip19} from "nostr-tools"
import {onMount} from "svelte"
import {derived} from "svelte/store"
import {page} from "$app/stores"
import {sleep, now, ctx} from "@welshman/lib"
import type {Readable} from "svelte/store"
import {now} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {throttled} from "@welshman/store"
import {feedsFromFilter, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds"
import {createEvent, MESSAGE, DELETE, REACTION} from "@welshman/util"
import {
formatTimestampAsDate,
createFeedController,
subscribe,
publishThunk,
deriveRelay,
} from "@welshman/app"
import {slide} from "@lib/transition"
import {createScroller, type Scroller} from "@lib/html"
import {formatTimestampAsDate, pubkey, publishThunk, deriveRelay, repository} from "@welshman/app"
import {slide, fade, fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import Divider from "@lib/components/Divider.svelte"
import type {getEditor} from "@app/editor"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import ChannelMessage from "@app/components/ChannelMessage.svelte"
import ChannelCompose from "@app/components/ChannelCompose.svelte"
import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte"
import {
userSettingValues,
decodeRelay,
deriveEventsForUrl,
GENERAL,
tagRoom,
LEGACY_MESSAGE,
userRoomsByUrl,
displayChannel,
getEventsForUrl,
} from "@app/state"
import {setChecked} from "@app/notifications"
import {nip29, addRoomMembership, removeRoomMembership, getThunkError} from "@app/commands"
import {setChecked, checked} from "@app/notifications"
import {
nip29,
addRoomMembership,
removeRoomMembership,
prependParent,
getThunkError,
} from "@app/commands"
import {PROTECTED, hasNip29} from "@app/state"
import {makeFeed} from "@app/requests"
import {popKey} from "@app/implicit"
import {pushToast} from "@app/toast"
const {room = GENERAL} = $page.params
const lastChecked = $checked[$page.url.pathname]
const content = popKey<string>("content") || ""
const url = decodeRelay($page.params.relay)
const filter = {kinds: [MESSAGE], "#h": [room]}
const relay = deriveRelay(url)
const legacyRoom = room === GENERAL ? "general" : room
const feeds = feedsFromFilter({kinds: [MESSAGE], "#h": [room]})
const events = throttled(
300,
deriveEventsForUrl(url, [
{kinds: [MESSAGE], "#h": [room]},
{kinds: [LEGACY_MESSAGE], "#~": [legacyRoom]},
]),
)
const ctrl = createFeedController({
useWindowing: true,
feed: makeIntersectionFeed(makeRelayFeed(url), ...feeds),
onExhausted: () => {
loading = false
},
})
const assertEvent = (e: any) => e as TrustedEvent
@@ -89,85 +69,130 @@
}
const replyTo = (event: TrustedEvent) => {
const relays = ctx.app.router.Event(event).getUrls()
const nevent = nip19.neventEncode({...event, relays})
$editor.commands.insertNEvent({nevent})
$editor.commands.insertContent("\n")
$editor.commands.focus()
parent = event
compose.focus()
}
const onSubmit = ({content, tags}: EventContent) =>
const clearParent = () => {
parent = undefined
}
const onSubmit = ({content, tags}: EventContent) => {
tags.push(tagRoom(room, url))
tags.push(PROTECTED)
publishThunk({
relays: [url],
event: createEvent(MESSAGE, {content, tags: [...tags, tagRoom(room, url), PROTECTED]}),
event: createEvent(MESSAGE, prependParent(parent, {content, tags})),
delay: $userSettingValues.send_delay,
})
let limit = 100
let loading = true
let unmounted = false
let element: HTMLElement
let scroller: Scroller
let editor: ReturnType<typeof getEditor>
clearParent()
}
const elements = derived(events, $events => {
const $elements = []
const onScroll = () => {
showScrollButton = Math.abs(element?.scrollTop || 0) > 1500
if (!newMessages || newMessagesSeen) {
showFixedNewMessages = false
} else {
const {y} = newMessages.getBoundingClientRect()
if (y > 300) {
newMessagesSeen = true
} else {
showFixedNewMessages = y < 0
}
}
}
const scrollToNewMessages = () =>
newMessages.scrollIntoView({behavior: "smooth", block: "center"})
const scrollToBottom = () => element.scrollTo({top: 0, behavior: "smooth"})
let parent: TrustedEvent | undefined
let loading = true
let element: HTMLElement
let newMessages: HTMLElement
let newMessagesSeen = false
let showFixedNewMessages = false
let showScrollButton = false
let cleanup: () => void
let events: Readable<TrustedEvent[]>
let compose: ChannelCompose
let elements: any[] = []
$: {
elements = []
const seen = new Set()
let previousDate
let previousPubkey
let newMessagesSeen = false
for (const event of $events.toReversed()) {
const {id, pubkey, created_at} = event
const date = formatTimestampAsDate(created_at)
if (events) {
for (const event of $events.toReversed()) {
const {id, pubkey, created_at} = event
if (date !== previousDate) {
$elements.push({type: "date", value: date, id: date, showPubkey: false})
if (seen.has(id)) {
continue
}
const date = formatTimestampAsDate(created_at)
if (
!newMessagesSeen &&
event.pubkey !== $pubkey &&
lastChecked &&
created_at > lastChecked
) {
elements.push({type: "new-messages", id: "new-messages"})
newMessagesSeen = true
}
if (date !== previousDate) {
elements.push({type: "date", value: date, id: date, showPubkey: false})
}
elements.push({
id,
type: "note",
value: event,
showPubkey: date !== previousDate || previousPubkey !== pubkey,
})
previousDate = date
previousPubkey = pubkey
seen.add(id)
}
$elements.push({
id,
type: "note",
value: event,
showPubkey: date !== previousDate || previousPubkey !== pubkey,
})
previousDate = date
previousPubkey = pubkey
}
return $elements.reverse()
})
elements.reverse()
setTimeout(onScroll, 100)
}
$: {
if (element) {
;({events, cleanup} = makeFeed({
element,
relays: [url],
feedFilters: [filter],
subscriptionFilters: [{kinds: [DELETE, REACTION, MESSAGE], "#h": [room], since: now()}],
initialEvents: getEventsForUrl(repository, url, [{...filter, limit: 20}]),
onExhausted: () => {
loading = false
},
}))
}
}
onMount(() => {
// Element is frequently not defined. I don't know why
sleep(1000).then(() => {
if (!unmounted) {
scroller = createScroller({
element,
delay: 300,
threshold: 10_000,
onScroll: () => {
limit += 100
if ($events.length - limit < 100) {
ctrl.load(200)
}
},
})
}
})
const sub = subscribe({
relays: [url],
filters: [{kinds: [DELETE, REACTION, MESSAGE], "#h": [room], since: now()}],
})
return () => {
unmounted = true
setChecked($page.url.pathname)
scroller?.stop()
sub.close()
cleanup()
}
})
</script>
@@ -198,13 +223,23 @@
</div>
</PageBar>
<div
class="scroll-container -mt-2 flex flex-grow flex-col-reverse overflow-auto py-2"
class="scroll-container -mt-2 flex flex-grow flex-col-reverse overflow-y-auto overflow-x-hidden py-2"
on:scroll={onScroll}
bind:this={element}>
{#each $elements.slice(0, limit) as { type, id, value, showPubkey } (id)}
{#if type === "date"}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
<div
bind:this={newMessages}
class="flex items-center py-2 text-xs transition-colors"
class:opacity-0={showFixedNewMessages}>
<div class="h-px flex-grow bg-primary" />
<p class="rounded-full bg-primary px-2 py-1 text-primary-content">New Messages</p>
<div class="h-px flex-grow bg-primary" />
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
<div in:slide class:-mt-4={!showPubkey}>
<div in:slide class:-mt-1={!showPubkey}>
<ChannelMessage {url} {room} {replyTo} event={assertEvent(value)} {showPubkey} />
</div>
{/if}
@@ -217,5 +252,27 @@
{/if}
</p>
</div>
<ChannelCompose bind:editor {content} {onSubmit} />
{#if showFixedNewMessages}
<div class="relative z-feature flex justify-center">
<div transition:fly={{duration: 200}} class="fixed top-12">
<Button class="btn btn-primary btn-xs rounded-full" on:click={scrollToNewMessages}>
New Messages
</Button>
</div>
</div>
{/if}
<div>
{#if parent}
<ChannelComposeParent event={parent} clear={clearParent} />
{/if}
<ChannelCompose bind:this={compose} {content} {onSubmit} />
</div>
</div>
{#if showScrollButton}
<div in:fade class="fixed bottom-14 right-4">
<Button class="btn btn-circle btn-neutral" on:click={scrollToBottom}>
<Icon icon="alt-arrow-down" />
</Button>
</div>
{/if}
@@ -3,7 +3,7 @@
import {derived} from "svelte/store"
import {page} from "$app/stores"
import {sortBy, min, nthEq, sleep} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} from "@welshman/util"
import {THREAD, COMMENT, getListTags, getPubkeyTagValues} from "@welshman/util"
import {throttled} from "@welshman/store"
import {feedFromFilters, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds"
import {createFeedController, userMutes} from "@welshman/app"
@@ -16,14 +16,16 @@
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import {THREAD_FILTER, COMMENT_FILTER, decodeRelay, deriveEventsForUrl} from "@app/state"
import {decodeRelay, deriveEventsForUrl} from "@app/state"
import {setChecked} from "@app/notifications"
import {pushModal} from "@app/modal"
const url = decodeRelay($page.params.relay)
const feed = feedFromFilters([THREAD_FILTER, COMMENT_FILTER])
const threads = deriveEventsForUrl(url, [THREAD_FILTER])
const comments = deriveEventsForUrl(url, [COMMENT_FILTER])
const threadFilter = {kinds: [THREAD]}
const commentFilter = {kinds: [COMMENT], "#K": [String(THREAD)]}
const feed = feedFromFilters([threadFilter, commentFilter])
const threads = deriveEventsForUrl(url, [threadFilter])
const comments = deriveEventsForUrl(url, [commentFilter])
const mutedPubkeys = getPubkeyTagValues(getListTags($userMutes))
const events = throttled(
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 527 KiB