Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7455b49f8d | |||
| ae00eb0b9c | |||
| b82e434c70 | |||
| 576c9c2c95 | |||
| cef046b3ae | |||
| 18ae6f6044 | |||
| 664f3c01e0 | |||
| 15e82c4e41 | |||
| 397ecf773e | |||
| 45397e7fb8 | |||
| 11aa841241 | |||
| cc1c18d55f | |||
| e3fbd69e6e | |||
| ac756bf266 | |||
| 8e28ff13e9 | |||
| d8b87db784 | |||
| 0b8c6c4a49 | |||
| 9f4f468bf0 | |||
| 7563dff621 | |||
| f782898b62 | |||
| d0601400cd | |||
| d262da39e5 | |||
| 7d617d8399 | |||
| d2b7db18af | |||
| 89c2690254 | |||
| 34945d1c42 | |||
| 43b207c4dc | |||
| 55efb3fdfd | |||
| c4a1ad2e33 | |||
| fd8442c632 | |||
| e0875eb9b9 | |||
| 962ac7d80c | |||
| 5338ee11bc | |||
| 6d2e9a037d | |||
| ac8530bd9a | |||
| f7d11cf124 | |||
| 72d85e5740 | |||
| e57b5721f6 | |||
| 4ba6c72459 | |||
| c33698c662 | |||
| cf4e40c4cf | |||
| 664da505cd | |||
| 573d4e3cfb | |||
| b2dc41f25b | |||
| b3bc0e4957 | |||
| 0e79e5b9cc | |||
| 34c7bfcffb | |||
| fd9fee8f50 |
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
android
|
||||
ios
|
||||
build
|
||||
@@ -5,11 +5,11 @@ VITE_PLATFORM_TERMS=https://flotilla.social/terms
|
||||
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
|
||||
VITE_PLATFORM_NAME=Flotilla
|
||||
VITE_PLATFORM_LOGO=static/flotilla.png
|
||||
VITE_PLATFORM_RELAY=
|
||||
VITE_PLATFORM_RELAYS=
|
||||
VITE_PLATFORM_ACCENT="#7161FF"
|
||||
VITE_PLATFORM_SECONDARY="#EB5E28"
|
||||
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
|
||||
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/
|
||||
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/,wss://indexer.coracle.social/
|
||||
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://bucket.coracle.social/
|
||||
VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
|
||||
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
|
||||
|
||||
@@ -1 +1 @@
|
||||
package-lock.json -diff
|
||||
pnpm-lock.yaml -diff
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['master']
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker-container
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
env:
|
||||
REGISTRY: docker.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -60,6 +60,7 @@ google-services.json
|
||||
GoogleService-Info.plist
|
||||
|
||||
# IDEs and editors
|
||||
.roo
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
pnpm run lint
|
||||
pnpm run check
|
||||
|
||||
if [[ ! -z $(cat package.json | grep 'link:') ]]; then
|
||||
echo "Some packages are linked to local files!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
# 1.1.0
|
||||
|
||||
* Add better theming support
|
||||
* Improve forms for entering invite codes
|
||||
* Rely more heavily on NIP 29 for rooms
|
||||
* Support multiple platform relays
|
||||
* Remove default general room
|
||||
* Remove room tag from threads/calendars
|
||||
* Show pubkey on profile detail
|
||||
* Support pasting pubkey into chat start dialog
|
||||
* Add minimal style for quoted messages
|
||||
|
||||
# 1.0.4
|
||||
|
||||
* Fix thunk status click handler
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
## Project Overview
|
||||
|
||||
Flotilla is a Discord-like Nostr client that operates on the concept of "relays as groups/spaces." Built with SvelteKit 2.5 and Svelte 5, it provides messaging, threads, calendar events, and social features across Nostr relays.
|
||||
|
||||
## Important Patterns
|
||||
|
||||
### Finding Code
|
||||
- Prefer navigating from one file to the next following imports when possible
|
||||
- If search is necessary, use `ack`, not `grep` or `rg`.
|
||||
|
||||
### Nostr Event Handling
|
||||
- Prefer seconds to milliseconds when handling nostr events.
|
||||
|
||||
### Styling Conventions
|
||||
- When styling html, prefer flex/gap classes over margin or space-y classes.
|
||||
|
||||
### Room/space memberships
|
||||
|
||||
Memberships are surfaced as "bookmarks" to the user.
|
||||
|
||||
```typescript
|
||||
import {membershipsByPubkey, getMembershipUrls} from '@app/state'
|
||||
|
||||
const spaces = getMembershipUrls($membershipsByPubkey.get(pubkey))
|
||||
const rooms = getMembershipRooms($membershipsByPubkey.get(pubkey))
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM node:20-slim
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm@latest
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm i
|
||||
|
||||
# Copy the rest of the application
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
ENV NODE_OPTIONS=--max_old_space_size=16384
|
||||
RUN pnpm run build
|
||||
|
||||
# Default to serving the build directory
|
||||
CMD ["npx", "serve", "build"]
|
||||
|
||||
@@ -4,14 +4,6 @@ A discord-like nostr client based on the idea of "relays as groups".
|
||||
|
||||
If you would like to be interoperable with Flotilla, please check out this guide: https://habla.news/u/hodlbod@coracle.social/1741286140797
|
||||
|
||||
# Deploy
|
||||
|
||||
To run your own Flotilla, it's as simple as:
|
||||
|
||||
- `pnpm install`
|
||||
- `pnpm run build`
|
||||
- `npx serve build`
|
||||
|
||||
## Environment
|
||||
|
||||
You can also optionally create an `.env` file and populate it with the following environment variables (see `.env` for examples):
|
||||
@@ -20,7 +12,7 @@ You can also optionally create an `.env` file and populate it with the following
|
||||
- `VITE_PLATFORM_URL` - The url where the app will be hosted. This is only used for build-time population of meta tags.
|
||||
- `VITE_PLATFORM_NAME` - The name of the app
|
||||
- `VITE_PLATFORM_LOGO` - A logo url for the app
|
||||
- `VITE_PLATFORM_RELAY` - A relay url that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the platform relay the home page.
|
||||
- `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
|
||||
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
||||
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
|
||||
- `VITE_GLITCHTIP_API_KEY` - A Sentry DSN for use with glitchtip (error reporting)
|
||||
@@ -28,84 +20,29 @@ You can also optionally create an `.env` file and populate it with the following
|
||||
|
||||
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
|
||||
|
||||
## Nginx/TLS (optional)
|
||||
## Development
|
||||
|
||||
If you'd like to set up flotilla on a server you control, you'll want to set up a reverse proxy and provision a TSL certificate for the domain you'll be using. You should also make sure to add swap to your server.
|
||||
Run `pnpm run dev` to get a dev server, and `pnpm run check:watch` to watch for typescript errors. When you're ready to commit, a pre-commit hook will run to lint and typecheck your work.
|
||||
|
||||
There will be some parts of the following templates, for example `<SERVER NAME>`, which you'll need to fill in before running the code.
|
||||
## Deployment
|
||||
|
||||
First, create an `A` record with your DNS provider pointing to the IP of your server. This will allow certbot to create your certificate later.
|
||||
|
||||
Next install `nginx`, `git`, and `certbot`. If you're on a debian- or ubuntu-based distro, run `sudo apt-get update && sudo apt-get install nginx git certbot python3-certbot-nginx`.
|
||||
|
||||
Now, create a new user where your code will be stored, clone the repository, fill in your `.env` file, and build the app.
|
||||
To run your own Flotilla, it's as simple as:
|
||||
|
||||
```sh
|
||||
# Replace with your password
|
||||
PASSWORD=<YOUR PASSWORD HERE>
|
||||
|
||||
# Add the user and set a password
|
||||
adduser flotilla
|
||||
echo flotilla:$PASSWORD | chpasswd
|
||||
|
||||
# Login as flotilla
|
||||
sudo su flotilla
|
||||
|
||||
# Go to flotilla's home directory
|
||||
cd ~
|
||||
|
||||
# Install nvm, yarn, clone repos
|
||||
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
|
||||
# Update PATH
|
||||
. ~/.bashrc
|
||||
|
||||
# Clone repository and install dependencies
|
||||
git clone https://github.com/coracle-social/flotilla.git
|
||||
cd ~/flotilla
|
||||
nvm install
|
||||
nvm use
|
||||
pnpm i
|
||||
|
||||
# Optionally create and populate .env to suit your use case
|
||||
|
||||
# Build the app
|
||||
NODE_OPTIONS=--max_old_space_size=16384 pnpm run build
|
||||
|
||||
# Exit back to root
|
||||
exit
|
||||
pnpm install
|
||||
pnpm run build
|
||||
npx serve build
|
||||
```
|
||||
|
||||
Once you've exited back to root, you can set up nginx. Place the following in a file named after your domain in the `/etc/nginx/sites-available` directory, for example, `flotilla.example.com`. This should match the `A` record you registered above.
|
||||
Or, if you prefer to use a container:
|
||||
|
||||
```conf
|
||||
server {
|
||||
listen 80;
|
||||
server_name <SERVER NAME>;
|
||||
root /home/flotilla/flotilla/build;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
```sh
|
||||
podman run -d -p 3000:3000 ghcr.io/coracle-social/flotilla:latest
|
||||
```
|
||||
|
||||
Now you can run `certbot`, which will provision a TLS certificate for your domain and update your nginx configuration.
|
||||
Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
|
||||
|
||||
```sh
|
||||
mkdir ./mount
|
||||
podman run -v ./mount:/app/mount ghcr.io/coracle-social/flotilla:latest bash -c 'cp -r build/* mount'
|
||||
```
|
||||
certbot --nginx -d <SERVER NAME>
|
||||
```
|
||||
|
||||
Now, enable the site and restart nginx. If you want to be careful, run `nginx -t` before restarting nginx.
|
||||
|
||||
```
|
||||
ln -s /etc/nginx/sites-{available,enabled}/<SERVER NAME>
|
||||
service nginx restart
|
||||
```
|
||||
|
||||
Now, visit your domain. You should be all set up!
|
||||
|
||||
# Development
|
||||
|
||||
Run `pnpm run dev` to get a dev server, and `pnpm run check:watch` to watch for typescript errors. When you're ready to commit, run `pnpm run format && pnpm run lint` and fix any errors that come up.
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "social.flotilla"
|
||||
minSdk rootProject.ext.minSdkVersion
|
||||
targetSdk rootProject.ext.targetSdkVersion
|
||||
versionCode 18
|
||||
versionName "1.0.4"
|
||||
versionCode 19
|
||||
versionName "1.1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -9,6 +9,7 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-community-safe-area')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-keyboard')
|
||||
implementation project(':nostr-signer-capacitor-plugin')
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-community-safe-area'
|
||||
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.2.0/node_modules/@capacitor-community/safe-area/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app/android')
|
||||
|
||||
|
||||
@@ -6,11 +6,8 @@ git describe --tags --abbrev=0
|
||||
export VITE_BUILD_VERSION=$RENDER_GIT_COMMIT
|
||||
export VITE_BUILD_HASH=$RENDER_GIT_COMMIT
|
||||
|
||||
# Remove link overrides
|
||||
node remove-pnpm-overrides.js package.json
|
||||
|
||||
# When CI=true as it is on render.com, removing link overrides breaks the lockfile
|
||||
pnpm i --no-frozen-lockfile
|
||||
# Install dependencies
|
||||
CI=0 pnpm i
|
||||
|
||||
# Rebuild sharp
|
||||
pnpm rebuild
|
||||
|
||||
@@ -351,14 +351,14 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 11;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.0.4;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -376,14 +376,14 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 11;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.0.4;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.2.0/node_modules/@capacitor-community/safe-area'
|
||||
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app'
|
||||
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard'
|
||||
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flotilla",
|
||||
"version": "1.0.4",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@eslint/js": "^9.26.0",
|
||||
"@sentry/cli": "^2.40.0",
|
||||
"@sveltejs/kit": "^2.5.27",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
@@ -37,32 +38,33 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@capacitor-community/safe-area": "7.0.0-alpha.1",
|
||||
"@capacitor/android": "^7.0.0",
|
||||
"@capacitor/app": "^7.0.0",
|
||||
"@capacitor/cli": "^7.0.0",
|
||||
"@capacitor/core": "^7.0.1",
|
||||
"@capacitor/ios": "^7.0.0",
|
||||
"@capacitor/keyboard": "^7.0.0",
|
||||
"@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/core": "^2.12.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@vite-pwa/assets-generator": "^0.2.6",
|
||||
"@vite-pwa/sveltekit": "^0.6.6",
|
||||
"@welshman/app": "^0.2.5",
|
||||
"@welshman/content": "^0.2.2",
|
||||
"@welshman/dvm": "^0.2.0",
|
||||
"@welshman/editor": "^0.2.4",
|
||||
"@welshman/feeds": "^0.2.2",
|
||||
"@welshman/lib": "^0.2.2",
|
||||
"@welshman/net": "^0.2.3",
|
||||
"@welshman/relay": "^0.2.0",
|
||||
"@welshman/router": "^0.2.0",
|
||||
"@welshman/signer": "^0.2.3",
|
||||
"@welshman/store": "^0.2.0",
|
||||
"@welshman/util": "^0.2.3",
|
||||
"@welshman/app": "^0.3.4",
|
||||
"@welshman/content": "^0.3.4",
|
||||
"@welshman/dvm": "^0.3.4",
|
||||
"@welshman/editor": "^0.3.4",
|
||||
"@welshman/feeds": "^0.3.4",
|
||||
"@welshman/lib": "^0.3.4",
|
||||
"@welshman/net": "^0.3.4",
|
||||
"@welshman/relay": "^0.3.4",
|
||||
"@welshman/router": "^0.3.4",
|
||||
"@welshman/signer": "^0.3.4",
|
||||
"@welshman/store": "^0.3.4",
|
||||
"@welshman/util": "^0.3.4",
|
||||
"compressorjs": "^1.2.1",
|
||||
"daisyui": "^4.12.10",
|
||||
"date-picker-svelte": "^2.13.0",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -71,25 +73,12 @@
|
||||
"husky": "^9.1.6",
|
||||
"idb": "^8.0.0",
|
||||
"nostr-signer-capacitor-plugin": "^0.0.4",
|
||||
"nostr-tools": "^2.7.2",
|
||||
"nostr-tools": "^2.14.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"qrcode": "^1.5.4"
|
||||
"qrcode": "^1.5.4",
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@welshman/lib": "link:../welshman/packages/lib",
|
||||
"@welshman/util": "link:../welshman/packages/util",
|
||||
"@welshman/app": "link:../welshman/packages/app",
|
||||
"@welshman/content": "link:../welshman/packages/content",
|
||||
"@welshman/dvm": "link:../welshman/packages/dvm",
|
||||
"@welshman/feeds": "link:../welshman/packages/feeds",
|
||||
"@welshman/net": "link:../welshman/packages/net",
|
||||
"@welshman/relay": "link:../welshman/packages/relay",
|
||||
"@welshman/router": "link:../welshman/packages/router",
|
||||
"@welshman/signer": "link:../welshman/packages/signer",
|
||||
"@welshman/store": "link:../welshman/packages/store",
|
||||
"@welshman/editor": "link:../welshman/packages/editor"
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"@sentry/cli",
|
||||
"esbuild"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// This script is necessary for installing stuff on a host, since our links don't exist there.
|
||||
|
||||
import fs from "fs"
|
||||
|
||||
const pkgName = process.argv[2]
|
||||
|
||||
if (!pkgName?.endsWith("package.json")) {
|
||||
console.log("File passed was not a package.json file")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgName, "utf8"))
|
||||
|
||||
if (pkg.pnpm && pkg.pnpm.overrides) {
|
||||
// Use $package notation to make sure we only get one copy of each welshman dependency
|
||||
// TODO: move welshman to a single package to straighten all this out.
|
||||
for (const k of Object.keys(pkg.pnpm.overrides)) {
|
||||
pkg.pnpm.overrides[k] = '$' + k
|
||||
}
|
||||
|
||||
fs.writeFileSync(pkgName, JSON.stringify(pkg, null, 2) + "\n")
|
||||
console.log("Removed pnpm.overrides from package.json")
|
||||
} else {
|
||||
console.log("No pnpm.overrides found in package.json")
|
||||
}
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
getRelaysFromList,
|
||||
RelayMode,
|
||||
} from "@welshman/util"
|
||||
import {Pool, PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
|
||||
import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
|
||||
import {Router} from "@welshman/router"
|
||||
import {
|
||||
pubkey,
|
||||
@@ -52,9 +52,8 @@ import {
|
||||
dropSession,
|
||||
tagEventForComment,
|
||||
tagEventForQuote,
|
||||
thunkIsComplete,
|
||||
getThunkError,
|
||||
} from "@welshman/app"
|
||||
import type {Thunk} from "@welshman/app"
|
||||
import {
|
||||
tagRoom,
|
||||
PROTECTED,
|
||||
@@ -83,21 +82,6 @@ export const getPubkeyPetname = (pubkey: string) => {
|
||||
return display
|
||||
}
|
||||
|
||||
export const getThunkError = (thunk: Thunk) =>
|
||||
new Promise<string>(resolve => {
|
||||
thunk.subscribe($thunk => {
|
||||
for (const [relay, status] of Object.entries($thunk.status)) {
|
||||
if (status === PublishStatus.Failure) {
|
||||
resolve($thunk.details[relay])
|
||||
}
|
||||
}
|
||||
|
||||
if (thunkIsComplete($thunk)) {
|
||||
resolve("")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
|
||||
if (parent) {
|
||||
const nevent = nip19.neventEncode({
|
||||
@@ -144,29 +128,30 @@ export const broadcastUserData = async (relays: string[]) => {
|
||||
|
||||
// NIP 29 stuff
|
||||
|
||||
export const nip29 = {
|
||||
createRoom: (url: string, room: string) => {
|
||||
const event = createEvent(GROUP_CREATE, {tags: [tagRoom(room, url)]})
|
||||
export const createRoom = (url: string, room: string) => {
|
||||
const event = createEvent(GROUP_CREATE, {tags: [tagRoom(room, url)]})
|
||||
|
||||
return publishThunk({event, relays: [url]})
|
||||
},
|
||||
editMeta: (url: string, room: string, meta: Record<string, string>) => {
|
||||
const event = createEvent(GROUP_EDIT_META, {
|
||||
tags: [tagRoom(room, url), ...Object.entries(meta)],
|
||||
})
|
||||
return publishThunk({event, relays: [url]})
|
||||
}
|
||||
|
||||
return publishThunk({event, relays: [url]})
|
||||
},
|
||||
joinRoom: (url: string, room: string) => {
|
||||
const event = createEvent(GROUP_JOIN, {tags: [tagRoom(room, url)]})
|
||||
export const editRoom = (url: string, room: string, meta: Record<string, string>) => {
|
||||
const event = createEvent(GROUP_EDIT_META, {
|
||||
tags: [tagRoom(room, url), ...Object.entries(meta)],
|
||||
})
|
||||
|
||||
return publishThunk({event, relays: [url]})
|
||||
},
|
||||
leaveRoom: (url: string, room: string) => {
|
||||
const event = createEvent(GROUP_LEAVE, {tags: [tagRoom(room, url)]})
|
||||
return publishThunk({event, relays: [url]})
|
||||
}
|
||||
|
||||
return publishThunk({event, relays: [url]})
|
||||
},
|
||||
export const joinRoom = (url: string, room: string) => {
|
||||
const event = createEvent(GROUP_JOIN, {tags: [tagRoom(room, url)]})
|
||||
|
||||
return publishThunk({event, relays: [url]})
|
||||
}
|
||||
|
||||
export const leaveRoom = (url: string, room: string) => {
|
||||
const event = createEvent(GROUP_LEAVE, {tags: [tagRoom(room, url)]})
|
||||
|
||||
return publishThunk({event, relays: [url]})
|
||||
}
|
||||
|
||||
// List updates
|
||||
@@ -188,11 +173,11 @@ export const removeSpaceMembership = async (url: string) => {
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const addRoomMembership = async (url: string, room: string, name: string) => {
|
||||
export const addRoomMembership = async (url: string, room: string) => {
|
||||
const list = get(userMembership) || makeList({kind: GROUPS})
|
||||
const newTags = [
|
||||
["r", url],
|
||||
["group", room, url, name],
|
||||
["group", room, url],
|
||||
]
|
||||
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
|
||||
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||
|
||||
@@ -14,13 +14,7 @@
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import InfoBunker from "@app/components/InfoBunker.svelte"
|
||||
import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
|
||||
import {
|
||||
GENERAL,
|
||||
alerts,
|
||||
getMembershipUrls,
|
||||
getMembershipRoomsByUrl,
|
||||
userMembership,
|
||||
} from "@app/state"
|
||||
import {alerts, getMembershipUrls, getMembershipRoomsByUrl, userMembership} from "@app/state"
|
||||
import {loadAlertStatuses} from "@app/requests"
|
||||
import {publishAlert} from "@app/commands"
|
||||
import {pushToast} from "@app/toast"
|
||||
@@ -107,7 +101,7 @@
|
||||
display.push("chat")
|
||||
filters.push({
|
||||
kinds: [MESSAGE],
|
||||
"#h": [GENERAL, ...getMembershipRoomsByUrl(relay, $userMembership)],
|
||||
"#h": getMembershipRoomsByUrl(relay, $userMembership),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import {PROTECTED, GENERAL, tagRoom} from "@app/state"
|
||||
import {PROTECTED} from "@app/state"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
["end", end.toString()],
|
||||
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
||||
...ed.storage.nostr.getEditorTags(),
|
||||
tagRoom(GENERAL, url),
|
||||
PROTECTED,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -20,14 +20,13 @@
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
room: string
|
||||
event: TrustedEvent
|
||||
replyTo?: (event: TrustedEvent) => void
|
||||
showPubkey?: boolean
|
||||
inert?: boolean
|
||||
}
|
||||
|
||||
const {url, room, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
|
||||
const {url, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
|
||||
|
||||
const thunk = $thunks[event.id]
|
||||
const today = formatTimestampAsDate(now())
|
||||
@@ -76,7 +75,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-sm">
|
||||
<Content {event} {url} />
|
||||
<Content minimalQuote {event} {url} />
|
||||
{#if thunk}
|
||||
<ThunkStatus {thunk} class="mt-2" />
|
||||
{/if}
|
||||
@@ -95,7 +94,7 @@
|
||||
<button
|
||||
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
|
||||
class:group-hover:opacity-100={!isMobile}>
|
||||
<ChannelMessageEmojiButton {url} {room} {event} />
|
||||
<ChannelMessageEmojiButton {url} {event} />
|
||||
{#if replyTo}
|
||||
<Button class="btn join-item btn-xs" onclick={reply}>
|
||||
<Icon icon="reply" size={4} />
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<script lang="ts">
|
||||
import {noop} from "@welshman/lib"
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import {publishReaction} from "@app/commands"
|
||||
|
||||
const {url, room, event} = $props()
|
||||
|
||||
// Tell svelte-check to shut up
|
||||
noop(room)
|
||||
const {url, event} = $props()
|
||||
|
||||
const onEmoji = (emoji: NativeEmoji) =>
|
||||
publishReaction({event, relays: [url], content: emoji.unicode})
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {GENERAL, channelsById, makeChannelId} from "@app/state"
|
||||
import {channelsById, makeChannelId} from "@app/state"
|
||||
|
||||
const {url, room} = $props()
|
||||
</script>
|
||||
|
||||
{#if room === GENERAL}
|
||||
general
|
||||
{:else}
|
||||
{$channelsById.get(makeChannelId(url, room))?.name || room}
|
||||
{/if}
|
||||
{$channelsById.get(makeChannelId(url, room))?.name || room}
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {onMount} from "svelte"
|
||||
import {int, nthNe, MINUTE, sortBy, remove, formatTimestampAsDate} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
|
||||
import {
|
||||
int,
|
||||
ms,
|
||||
partition,
|
||||
spec,
|
||||
nthEq,
|
||||
nthNe,
|
||||
MINUTE,
|
||||
sortBy,
|
||||
remove,
|
||||
formatTimestampAsDate,
|
||||
} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util"
|
||||
import {parse, isLink} from "@welshman/content"
|
||||
import {
|
||||
createEvent,
|
||||
tagsFromIMeta,
|
||||
getTags,
|
||||
DIRECT_MESSAGE,
|
||||
DIRECT_MESSAGE_FILE,
|
||||
INBOX_RELAYS,
|
||||
} from "@welshman/util"
|
||||
import {
|
||||
pubkey,
|
||||
tagPubkey,
|
||||
@@ -61,14 +80,53 @@
|
||||
}
|
||||
|
||||
const onSubmit = async (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 ptags = remove($pubkey!, pubkeys).map(tagPubkey)
|
||||
|
||||
await sendWrapped({
|
||||
pubkeys,
|
||||
template: createEvent(DIRECT_MESSAGE, prependParent(parent, {...params, tags})),
|
||||
delay: $userSettingValues.send_delay,
|
||||
})
|
||||
// Remove p tags since they result in forking the conversation
|
||||
params.tags = params.tags.filter(nthNe(0, "p"))
|
||||
|
||||
// Add our reply quote to content
|
||||
params = prependParent(parent, params)
|
||||
|
||||
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
|
||||
const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta)
|
||||
const templates: EventTemplate[] = []
|
||||
const buffer = []
|
||||
|
||||
const addTemplate = (kind: number, content: string, tags: string[][]) => {
|
||||
content = content.trim()
|
||||
|
||||
if (content) {
|
||||
templates.push(createEvent(kind, {content, tags: [...tags, ...ptags]}))
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of parse(params)) {
|
||||
const imeta = isLink(p)
|
||||
? imetas.find(tags => tags.find(spec(["url", p.value.url.toString()])))
|
||||
: undefined
|
||||
|
||||
if (isLink(p) && imeta) {
|
||||
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
||||
addTemplate(
|
||||
DIRECT_MESSAGE_FILE,
|
||||
p.value.url.toString(),
|
||||
imeta.slice(1).filter(nthNe(0, "url")),
|
||||
)
|
||||
} else {
|
||||
buffer.push(p.raw)
|
||||
}
|
||||
}
|
||||
|
||||
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
||||
|
||||
// Split the message into multiple pieces so that we can use kind 15 to send images per nip 17
|
||||
// Sleep 1 second between each one to make sure timestamps are distinct
|
||||
for (let i = 0; i < templates.length; i++) {
|
||||
const template = templates[i]
|
||||
|
||||
await sendWrapped({pubkeys, template, delay: $userSettingValues.send_delay + ms(i)})
|
||||
}
|
||||
|
||||
clearParent()
|
||||
}
|
||||
@@ -191,7 +249,7 @@
|
||||
{/snippet}
|
||||
</PageBar>
|
||||
|
||||
<PageContent class="flex flex-col-reverse pt-4">
|
||||
<PageContent class="flex flex-col-reverse gap-2 pt-4">
|
||||
<div bind:this={dynamicPadding}></div>
|
||||
{#if missingInboxes.includes($pubkey!)}
|
||||
<div class="py-12">
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
export const focus = () => editor.then(ed => ed.chain().focus().run())
|
||||
|
||||
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
||||
|
||||
const submit = async () => {
|
||||
if ($uploading) return
|
||||
|
||||
@@ -40,11 +42,21 @@
|
||||
submit,
|
||||
uploading,
|
||||
aggressive: true,
|
||||
disableFileUpload: true,
|
||||
})
|
||||
</script>
|
||||
|
||||
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
|
||||
disabled={$uploading}
|
||||
onclick={uploadFiles}>
|
||||
{#if $uploading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Icon icon="gallery-send" />
|
||||
{/if}
|
||||
</Button>
|
||||
<div class="chat-editor flex-grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
const {...props}: Props = $props()
|
||||
|
||||
const others = remove($pubkey!, props.pubkeys)
|
||||
const active = $page.params.chat === props.id
|
||||
const active = $derived($page.params.chat === props.id)
|
||||
const path = makeChatPath(props.pubkeys)
|
||||
|
||||
onMount(() => {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {onMount} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import {goto} from "$app/navigation"
|
||||
import {tryCatch, uniq} from "@welshman/lib"
|
||||
import {fromNostrURI} from "@welshman/util"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
@@ -14,7 +19,36 @@
|
||||
|
||||
const onSubmit = () => goto(makeChatPath([...pubkeys, $pubkey!]))
|
||||
|
||||
const addPubkey = (pubkey: string) => {
|
||||
pubkeys = uniq([...pubkeys, pubkey])
|
||||
term.set("")
|
||||
}
|
||||
|
||||
const term = writable("")
|
||||
|
||||
let pubkeys: string[] = $state([])
|
||||
|
||||
onMount(() => {
|
||||
return term.subscribe(t => {
|
||||
if (t.match(/^[0-9a-f]{64}$/)) {
|
||||
addPubkey(t)
|
||||
}
|
||||
|
||||
if (t.match(/^(nostr:)?(npub1|nprofile1)/)) {
|
||||
tryCatch(() => {
|
||||
const {type, data} = nip19.decode(fromNostrURI(t))
|
||||
|
||||
if (type === "npub") {
|
||||
addPubkey(data)
|
||||
}
|
||||
|
||||
if (type === "nprofile") {
|
||||
addPubkey(data.pubkey)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
|
||||
@@ -28,7 +62,7 @@
|
||||
</ModalHeader>
|
||||
<Field>
|
||||
{#snippet input()}
|
||||
<ProfileMultiSelect autofocus bind:value={pubkeys} />
|
||||
<ProfileMultiSelect autofocus bind:value={pubkeys} {term} />
|
||||
{/snippet}
|
||||
</Field>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
showEntire?: boolean
|
||||
hideMediaAtDepth?: number
|
||||
expandMode?: string
|
||||
minimalQuote?: boolean
|
||||
depth?: number
|
||||
url?: string
|
||||
}
|
||||
@@ -51,6 +52,7 @@
|
||||
showEntire = $bindable(false),
|
||||
hideMediaAtDepth = 1,
|
||||
expandMode = "block",
|
||||
minimalQuote = false,
|
||||
depth = 0,
|
||||
url,
|
||||
}: Props = $props()
|
||||
@@ -153,7 +155,13 @@
|
||||
<ContentMention value={parsed.value} {url} />
|
||||
{:else if isEvent(parsed) || isAddress(parsed)}
|
||||
{#if isBlock(i)}
|
||||
<ContentQuote {depth} {url} {hideMediaAtDepth} value={parsed.value} {event} />
|
||||
<ContentQuote
|
||||
{depth}
|
||||
{url}
|
||||
{hideMediaAtDepth}
|
||||
value={parsed.value}
|
||||
{event}
|
||||
minimal={minimalQuote} />
|
||||
{:else}
|
||||
<Link
|
||||
external
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</script>
|
||||
|
||||
<Link external href={url} class="my-2 block">
|
||||
<div class="overflow-hidden rounded-box leading-[0]">
|
||||
<div class="overflow-hidden rounded-box">
|
||||
{#if url.match(/\.(mov|webm|mp4)$/)}
|
||||
<video controls src={url} class="max-h-96 object-contain object-center">
|
||||
<track kind="captions" />
|
||||
|
||||
@@ -1,49 +1,52 @@
|
||||
<script lang="ts">
|
||||
import {onDestroy} from "svelte"
|
||||
import {now} from "@welshman/lib"
|
||||
import {BLOSSOM_AUTH, makeEvent, getTags, getTagValue, tagsFromIMeta} from "@welshman/util"
|
||||
import {signer} from "@welshman/app"
|
||||
import {onMount, onDestroy} from "svelte"
|
||||
import {displayUrl} from "@welshman/lib"
|
||||
import {getTags, decryptFile, getTagValue, tagsFromIMeta} from "@welshman/util"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import {imgproxy} from "@app/state"
|
||||
|
||||
const {value, event, ...props} = $props()
|
||||
|
||||
const url = value.url.toString()
|
||||
|
||||
// If we fail to fetch the image, try authenticating if we have a blossom hash
|
||||
const onerror = async () => {
|
||||
const meta = getTags("imeta", event.tags)
|
||||
const meta =
|
||||
getTags("imeta", event.tags)
|
||||
.map(tagsFromIMeta)
|
||||
.find(meta => getTagValue("url", meta) === url)
|
||||
const hash = meta ? getTagValue("x", meta) : undefined
|
||||
.find(meta => getTagValue("url", meta) === url) || event.tags
|
||||
|
||||
if (hash && $signer) {
|
||||
const event = await signer.get().sign(
|
||||
makeEvent(BLOSSOM_AUTH, {
|
||||
tags: [
|
||||
["t", "get"],
|
||||
["x", hash],
|
||||
["expiration", String(now() + 30)],
|
||||
],
|
||||
}),
|
||||
)
|
||||
const key = getTagValue("decryption-key", meta)
|
||||
const nonce = getTagValue("decryption-nonce", meta)
|
||||
const algorithm = getTagValue("encryption-algorithm", meta)
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Nostr ${btoa(JSON.stringify(event))}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (res.status === 200) {
|
||||
src = URL.createObjectURL(await res.blob())
|
||||
}
|
||||
}
|
||||
const onError = () => {
|
||||
hasError = true
|
||||
}
|
||||
|
||||
let hasError = $state(false)
|
||||
let src = $state(imgproxy(url))
|
||||
|
||||
onMount(async () => {
|
||||
if (algorithm === "aes-gcm" && key && nonce) {
|
||||
const response = await fetch(url)
|
||||
|
||||
if (response.ok) {
|
||||
const ciphertext = new Uint8Array(await response.arrayBuffer())
|
||||
const decryptedData = await decryptFile({ciphertext, key, nonce, algorithm})
|
||||
|
||||
src = URL.createObjectURL(new Blob([decryptedData]))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
URL.revokeObjectURL(src)
|
||||
})
|
||||
</script>
|
||||
|
||||
<img alt="" {src} {onerror} {...props} />
|
||||
{#if hasError}
|
||||
<a href={url} class="link-content whitespace-nowrap">
|
||||
<Icon icon="link-round" size={3} class="inline-block" />
|
||||
{displayUrl(url)}
|
||||
</a>
|
||||
{:else}
|
||||
<img alt="" {src} onerror={onError} {...props} />
|
||||
{/if}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<script lang="ts">
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {goto} from "$app/navigation"
|
||||
import {nthEq} from "@welshman/lib"
|
||||
import {Router} from "@welshman/router"
|
||||
import {tracker, repository} from "@welshman/app"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {Address, DIRECT_MESSAGE, MESSAGE, THREAD, EVENT_TIME} from "@welshman/util"
|
||||
import {scrollToEvent} from "@lib/html"
|
||||
import {Address, MESSAGE} from "@welshman/util"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import NoteCard from "@app/components/NoteCard.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import {deriveEvent, entityLink, ROOM} from "@app/state"
|
||||
import {makeThreadPath, makeCalendarPath, makeRoomPath} from "@app/routes"
|
||||
import {deriveEvent, entityLink} from "@app/state"
|
||||
import {goToEvent} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
value: any
|
||||
@@ -20,9 +16,10 @@
|
||||
event: TrustedEvent
|
||||
depth: number
|
||||
url?: string
|
||||
minimal?: boolean
|
||||
}
|
||||
|
||||
const {value, event, depth, hideMediaAtDepth, url}: Props = $props()
|
||||
const {value, event, depth, hideMediaAtDepth, url, minimal}: Props = $props()
|
||||
|
||||
const {id, identifier, kind, pubkey, relays = []} = value
|
||||
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
|
||||
@@ -37,67 +34,28 @@
|
||||
? nip19.neventEncode({id, relays: mergedRelays})
|
||||
: new Address(kind, pubkey, identifier, mergedRelays).toNaddr()
|
||||
|
||||
const openMessage = (url: string, room: string, id: string) => {
|
||||
const event = repository.getEvent(id)
|
||||
|
||||
if (event) {
|
||||
goto(makeRoomPath(url, room))
|
||||
scrollToEvent(id)
|
||||
}
|
||||
|
||||
return Boolean(event)
|
||||
}
|
||||
|
||||
const onclick = () => {
|
||||
if ($quote) {
|
||||
if ($quote.kind === DIRECT_MESSAGE) {
|
||||
return scrollToEvent($quote.id)
|
||||
}
|
||||
|
||||
const [url] = tracker.getRelays($quote.id)
|
||||
const room = $quote.tags.find(nthEq(0, ROOM))?.[1]
|
||||
|
||||
if (url && room) {
|
||||
if ($quote.kind === THREAD) {
|
||||
return goto(makeThreadPath(url, $quote.id))
|
||||
}
|
||||
|
||||
if ($quote.kind === EVENT_TIME) {
|
||||
return goto(makeCalendarPath(url, $quote.id))
|
||||
}
|
||||
|
||||
if ($quote.kind === MESSAGE) {
|
||||
return scrollToEvent($quote.id) || openMessage(url, room, $quote.id)
|
||||
}
|
||||
|
||||
const kind = $quote.tags.find(nthEq(0, "K"))?.[1]
|
||||
const id = $quote.tags.find(nthEq(0, "E"))?.[1]
|
||||
|
||||
if (id && kind) {
|
||||
if (parseInt(kind) === THREAD) {
|
||||
return goto(makeThreadPath(url, id))
|
||||
}
|
||||
|
||||
if (parseInt(kind) === EVENT_TIME) {
|
||||
return goto(makeCalendarPath(url, id))
|
||||
}
|
||||
|
||||
if (parseInt(kind) === MESSAGE) {
|
||||
return scrollToEvent(id) || openMessage(url, room, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
goToEvent($quote)
|
||||
} else {
|
||||
window.open(entityLink(entity))
|
||||
}
|
||||
|
||||
window.open(entityLink(entity))
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button class="my-2 block max-w-full text-left" {onclick}>
|
||||
{#if $quote}
|
||||
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
|
||||
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
|
||||
</NoteCard>
|
||||
{#if minimal && $quote.kind === MESSAGE}
|
||||
<div
|
||||
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
|
||||
style="background-color: color-mix(in srgb, var(--primary) 10%, var(--base-300) 90%);">
|
||||
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
|
||||
</div>
|
||||
{:else}
|
||||
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
|
||||
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
|
||||
</NoteCard>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="rounded-box p-4">
|
||||
<Spinner loading>Loading event...</Spinner>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import {publishComment} from "@app/commands"
|
||||
import {tagRoom, GENERAL, PROTECTED} from "@app/state"
|
||||
import {PROTECTED} from "@app/state"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
const ed = await editor
|
||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||
const tags = [...ed.storage.nostr.getEditorTags(), tagRoom(GENERAL, url), PROTECTED]
|
||||
const tags = [...ed.storage.nostr.getEditorTags(), PROTECTED]
|
||||
|
||||
if (!content) {
|
||||
return pushToast({
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import {PLATFORM_NAME} from "@app/state"
|
||||
</script>
|
||||
|
||||
<div class="column gap-4">
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Where did my rooms go?</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<p>
|
||||
You might have noticed that old rooms have disappeared from navigation. {PLATFORM_NAME} is still
|
||||
under heavy development, which means that we occasionally have to make breaking changes. In this
|
||||
case, we've changed how rooms work in {PLATFORM_NAME} to be more fully compatible with other NIP
|
||||
29 clients, like <Link external class="link" href="https://chachi.chat">Chachi</Link> and
|
||||
<Link external class="link" href="https://0xchat.com">0xChat</Link>.
|
||||
</p>
|
||||
<p>
|
||||
If you run a relay, please upgrade to a version that supports NIP 29. {PLATFORM_NAME} works best
|
||||
with the latest version of <Link
|
||||
external
|
||||
class="link"
|
||||
href="https://github.com/coracle-social/frith">Frith</Link
|
||||
>, which will automatically migrate your rooms. In the meantime, your messages are all still
|
||||
available under the "Chat" tab (all conversations have been temporarily merged together).
|
||||
</p>
|
||||
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
|
||||
</div>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import {deriveRelay} from "@welshman/app"
|
||||
import {fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
@@ -14,12 +15,14 @@
|
||||
import ProfileList from "@app/components/ProfileList.svelte"
|
||||
import RoomCreate from "@app/components/RoomCreate.svelte"
|
||||
import MenuSpaceRoomItem from "@app/components/MenuSpaceRoomItem.svelte"
|
||||
import InfoMissingRooms from "@app/components/InfoMissingRooms.svelte"
|
||||
import {
|
||||
userRoomsByUrl,
|
||||
hasMembershipUrl,
|
||||
memberships,
|
||||
deriveUserRooms,
|
||||
deriveOtherRooms,
|
||||
hasNip29,
|
||||
} from "@app/state"
|
||||
import {notifications} from "@app/notifications"
|
||||
import {pushModal} from "@app/modal"
|
||||
@@ -27,6 +30,8 @@
|
||||
|
||||
const {url} = $props()
|
||||
|
||||
const relay = deriveRelay(url)
|
||||
const chatPath = makeSpacePath(url, "chat")
|
||||
const threadsPath = makeSpacePath(url, "threads")
|
||||
const calendarPath = makeSpacePath(url, "calendar")
|
||||
const userRooms = deriveUserRooms(url)
|
||||
@@ -40,6 +45,8 @@
|
||||
showMenu = !showMenu
|
||||
}
|
||||
|
||||
const showMissingRooms = () => pushModal(InfoMissingRooms)
|
||||
|
||||
const showMembers = () =>
|
||||
pushModal(
|
||||
ProfileList,
|
||||
@@ -125,28 +132,43 @@
|
||||
notification={$notifications.has(calendarPath)}>
|
||||
<Icon icon="calendar-minimalistic" /> Calendar
|
||||
</SecondaryNavItem>
|
||||
<div class="h-2"></div>
|
||||
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
|
||||
{#each $userRooms as room, i (room)}
|
||||
<MenuSpaceRoomItem {replaceState} notify {url} {room} />
|
||||
{/each}
|
||||
{#if $otherRooms.length > 0}
|
||||
<div class="h-2"></div>
|
||||
<SecondaryNavHeader>
|
||||
{#if $userRooms.length > 0}
|
||||
Other Rooms
|
||||
{:else}
|
||||
Rooms
|
||||
{/if}
|
||||
</SecondaryNavHeader>
|
||||
{#if hasNip29($relay)}
|
||||
{#if $userRooms.length > 0}
|
||||
<div class="h-2"></div>
|
||||
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
|
||||
{/if}
|
||||
{#each $userRooms as room, i (room)}
|
||||
<MenuSpaceRoomItem {replaceState} notify {url} {room} />
|
||||
{/each}
|
||||
{#if $otherRooms.length > 0}
|
||||
<div class="h-2"></div>
|
||||
<SecondaryNavHeader>
|
||||
{#if $userRooms.length > 0}
|
||||
Other Rooms
|
||||
{:else}
|
||||
Rooms
|
||||
{/if}
|
||||
</SecondaryNavHeader>
|
||||
{/if}
|
||||
{#each $otherRooms as room, i (room)}
|
||||
<MenuSpaceRoomItem {replaceState} {url} {room} />
|
||||
{/each}
|
||||
<SecondaryNavItem {replaceState} onclick={addRoom}>
|
||||
<Icon icon="add-circle" />
|
||||
Create room
|
||||
</SecondaryNavItem>
|
||||
{:else}
|
||||
<SecondaryNavItem
|
||||
{replaceState}
|
||||
href={chatPath}
|
||||
notification={$notifications.has(chatPath)}>
|
||||
<Icon icon="chat-round" /> Chat
|
||||
</SecondaryNavItem>
|
||||
<Button class="link flex items-center gap-2 py-2 pl-4 text-sm" onclick={showMissingRooms}>
|
||||
<Icon icon="info-circle" size={4} />
|
||||
Where did my rooms go?
|
||||
</Button>
|
||||
{/if}
|
||||
{#each $otherRooms as room, i (room)}
|
||||
<MenuSpaceRoomItem {replaceState} {url} {room} />
|
||||
{/each}
|
||||
<SecondaryNavItem {replaceState} onclick={addRoom}>
|
||||
<Icon icon="add-circle" />
|
||||
Create room
|
||||
</SecondaryNavItem>
|
||||
</div>
|
||||
</SecondaryNavSection>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
||||
import ChannelName from "@app/components/ChannelName.svelte"
|
||||
import {makeRoomPath} from "@app/routes"
|
||||
import {deriveChannel, channelIsLocked} from "@app/state"
|
||||
import {deriveChannel} from "@app/state"
|
||||
import {notifications} from "@app/notifications"
|
||||
|
||||
interface Props {
|
||||
@@ -23,7 +23,7 @@
|
||||
href={path}
|
||||
{replaceState}
|
||||
notification={notify ? $notifications.has(path) : false}>
|
||||
{#if channelIsLocked($channel)}
|
||||
{#if $channel?.closed || $channel?.private}
|
||||
<Icon icon="lock" size={4} />
|
||||
{:else}
|
||||
<Icon icon="hashtag" />
|
||||
|
||||
@@ -5,30 +5,34 @@
|
||||
import CardButton from "@lib/components/CardButton.svelte"
|
||||
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
|
||||
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
||||
import {userRoomsByUrl} from "@app/state"
|
||||
import {userRoomsByUrl, PLATFORM_RELAYS} from "@app/state"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
const addSpace = () => pushModal(SpaceAdd)
|
||||
</script>
|
||||
|
||||
<div class="column menu gap-2">
|
||||
{#if $userRoomsByUrl.size > 0}
|
||||
{#each $userRoomsByUrl.keys() as url (url)}
|
||||
<MenuSpacesItem {url} />
|
||||
{/each}
|
||||
<Divider />
|
||||
{/if}
|
||||
<Button onclick={addSpace}>
|
||||
<CardButton>
|
||||
{#snippet icon()}
|
||||
<div><Icon icon="login-2" size={7} /></div>
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Add a space</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Join or create a new space</div>
|
||||
{/snippet}
|
||||
</CardButton>
|
||||
</Button>
|
||||
{#each PLATFORM_RELAYS as url (url)}
|
||||
<MenuSpacesItem {url} />
|
||||
{:else}
|
||||
{#if $userRoomsByUrl.size > 0}
|
||||
{#each $userRoomsByUrl.keys() as url (url)}
|
||||
<MenuSpacesItem {url} />
|
||||
{/each}
|
||||
<Divider />
|
||||
{/if}
|
||||
<Button onclick={addSpace}>
|
||||
<CardButton>
|
||||
{#snippet icon()}
|
||||
<div><Icon icon="login-2" size={7} /></div>
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Add a space</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Join or create a new space</div>
|
||||
{/snippet}
|
||||
</CardButton>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {formatTimestampRelative} from "@welshman/lib"
|
||||
import type {Filter} from "@welshman/util"
|
||||
import {deriveEvents} from "@welshman/store"
|
||||
import {load} from "@welshman/net"
|
||||
import {Router} from "@welshman/router"
|
||||
import {repository, loadRelaySelections} from "@welshman/app"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Profile from "@app/components/Profile.svelte"
|
||||
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
||||
import {pubkeyLink} from "@app/state"
|
||||
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
@@ -19,37 +14,21 @@
|
||||
|
||||
const {pubkey, url}: Props = $props()
|
||||
|
||||
const filters: Filter[] = [{authors: [pubkey], limit: 1}]
|
||||
const events = deriveEvents(repository, {filters})
|
||||
|
||||
onMount(async () => {
|
||||
// Make sure we have their relay selections before we load their posts
|
||||
await loadRelaySelections(pubkey)
|
||||
|
||||
// Load at least one note, regardless of time frame
|
||||
load({
|
||||
filters: [{authors: [pubkey], limit: 1}],
|
||||
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
|
||||
})
|
||||
})
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
|
||||
</script>
|
||||
|
||||
<div class="card2 bg-alt col-2 shadow-xl">
|
||||
<div class="card2 bg-alt flex flex-col gap-4 shadow-xl">
|
||||
<div class="flex justify-between">
|
||||
<Profile {pubkey} {url} />
|
||||
<Link external href={pubkeyLink(pubkey)} class="btn btn-primary hidden sm:flex">
|
||||
<Button onclick={openProfile} class="btn btn-primary hidden sm:flex">
|
||||
<Icon icon="user-circle" />
|
||||
See Complete Profile
|
||||
</Link>
|
||||
View Profile
|
||||
</Button>
|
||||
</div>
|
||||
<ProfileInfo {pubkey} {url} />
|
||||
{#if $events.length > 0}
|
||||
<div class="bg-alt badge badge-neutral border-none">
|
||||
Last active {formatTimestampRelative($events[0].created_at)}
|
||||
</div>
|
||||
{/if}
|
||||
<Link external href={pubkeyLink(pubkey)} class="btn btn-primary sm:hidden">
|
||||
<ProfileBadges {pubkey} {url} />
|
||||
<Button onclick={openProfile} class="btn btn-primary sm:hidden">
|
||||
<Icon icon="user-circle" />
|
||||
See Complete Profile
|
||||
</Link>
|
||||
View Profile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import MenuOtherSpaces from "@app/components/MenuOtherSpaces.svelte"
|
||||
import MenuSettings from "@app/components/MenuSettings.svelte"
|
||||
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
||||
import {userRoomsByUrl, canDecrypt, PLATFORM_RELAY, PLATFORM_LOGO} from "@app/state"
|
||||
import {userRoomsByUrl, canDecrypt, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/state"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
import {notifications} from "@app/notifications"
|
||||
@@ -57,8 +57,8 @@
|
||||
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
|
||||
<div class="flex h-full flex-col justify-between">
|
||||
<div>
|
||||
{#if PLATFORM_RELAY}
|
||||
<PrimaryNavItemSpace url={PLATFORM_RELAY} />
|
||||
{#each PLATFORM_RELAYS as url (url)}
|
||||
<PrimaryNavItemSpace {url} />
|
||||
{:else}
|
||||
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
|
||||
<Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" />
|
||||
@@ -79,7 +79,7 @@
|
||||
<PrimaryNavItem title="Add Space" onclick={addSpace} class="tooltip-right">
|
||||
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
|
||||
</PrimaryNavItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div>
|
||||
<PrimaryNavItem
|
||||
@@ -120,7 +120,7 @@
|
||||
notification={$notifications.has("/chat")}>
|
||||
<Avatar icon="letter" class="!h-10 !w-10" />
|
||||
</PrimaryNavItem>
|
||||
{#if !PLATFORM_RELAY}
|
||||
{#if PLATFORM_RELAYS.length !== 1}
|
||||
<PrimaryNavItem
|
||||
title="Spaces"
|
||||
onclick={showSpacesMenu}
|
||||
|
||||
@@ -1,54 +1,63 @@
|
||||
<script lang="ts">
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {removeNil} from "@welshman/lib"
|
||||
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
|
||||
import {displayPubkey} from "@welshman/util"
|
||||
import {
|
||||
session,
|
||||
userFollows,
|
||||
deriveUserWotScore,
|
||||
deriveHandleForPubkey,
|
||||
displayHandle,
|
||||
deriveProfile,
|
||||
deriveProfileDisplay,
|
||||
} from "@welshman/app"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Avatar from "@lib/components/Avatar.svelte"
|
||||
import WotScore from "@lib/components/WotScore.svelte"
|
||||
import WotScore from "@app/components/WotScore.svelte"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {clip} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
url?: string
|
||||
showPubkey?: boolean
|
||||
avatarSize?: number
|
||||
}
|
||||
|
||||
const {pubkey, url}: Props = $props()
|
||||
const {pubkey, url, showPubkey, avatarSize = 10}: Props = $props()
|
||||
|
||||
const relays = removeNil([url])
|
||||
const profile = deriveProfile(pubkey, relays)
|
||||
const profileDisplay = deriveProfileDisplay(pubkey, relays)
|
||||
const handle = deriveHandleForPubkey(pubkey)
|
||||
const score = deriveUserWotScore(pubkey)
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
|
||||
|
||||
const following = $derived(
|
||||
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey),
|
||||
)
|
||||
const copyPubkey = () => clip(nip19.npubEncode(pubkey))
|
||||
</script>
|
||||
|
||||
<div class="flex max-w-full gap-3">
|
||||
<div class="flex max-w-full items-start gap-3">
|
||||
<Button onclick={openProfile} class="py-1">
|
||||
<Avatar src={$profile?.picture} size={10} />
|
||||
<Avatar src={$profile?.picture} size={avatarSize} />
|
||||
</Button>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis">
|
||||
{$profileDisplay}
|
||||
</Button>
|
||||
<WotScore score={$score} active={following} />
|
||||
</div>
|
||||
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
|
||||
{$handle ? displayHandle($handle) : displayPubkey(pubkey)}
|
||||
<WotScore {pubkey} />
|
||||
</div>
|
||||
{#if $handle}
|
||||
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
|
||||
{displayHandle($handle)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showPubkey}
|
||||
<div class="flex items-center gap-1 overflow-hidden text-ellipsis text-xs opacity-60">
|
||||
{displayPubkey(pubkey)}
|
||||
<Button onclick={copyPubkey} class="pt-1">
|
||||
<Icon size={3} icon="copy" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {load} from "@welshman/net"
|
||||
import {Router} from "@welshman/router"
|
||||
import type {Filter} from "@welshman/util"
|
||||
import {deriveEvents} from "@welshman/store"
|
||||
import {formatTimestampRelative} from "@welshman/lib"
|
||||
import {NOTE, GROUPS, MESSAGE, THREAD, COMMENT, getRelayTags, getListTags} from "@welshman/util"
|
||||
import {repository, loadRelaySelections} from "@welshman/app"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileSpaces from "@app/components/ProfileSpaces.svelte"
|
||||
import {membershipsByPubkey} from "@app/state"
|
||||
import {goToEvent} from "@app/routes"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const {pubkey, url}: Props = $props()
|
||||
const filters: Filter[] = [{authors: [pubkey], limit: 1}]
|
||||
const events = deriveEvents(repository, {filters})
|
||||
const membership = $derived($membershipsByPubkey.get(pubkey))
|
||||
const relays = $derived(getRelayTags(getListTags(membership)))
|
||||
|
||||
const viewEvent = () => goToEvent($events[0]!)
|
||||
|
||||
const openSpaces = () => pushModal(ProfileSpaces, {pubkey, url})
|
||||
|
||||
onMount(async () => {
|
||||
// Make sure we have their relay selections before we load their posts
|
||||
await loadRelaySelections(pubkey)
|
||||
|
||||
// Load groups and at least one note, regardless of time frame
|
||||
load({
|
||||
filters: [
|
||||
{authors: [pubkey], kinds: [GROUPS]},
|
||||
{authors: [pubkey], limit: 1, kinds: [NOTE, MESSAGE, THREAD, COMMENT]},
|
||||
],
|
||||
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if $events.length > 0}
|
||||
<Button onclick={viewEvent} class="badge badge-neutral">
|
||||
Last active {formatTimestampRelative($events[0].created_at)}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if relays.length > 0}
|
||||
<Button onclick={openSpaces} class="badge badge-neutral">
|
||||
{relays.length}
|
||||
{relays.length === 1 ? "space" : "spaces"}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -5,7 +5,7 @@
|
||||
</script>
|
||||
|
||||
<div class="flex pr-3">
|
||||
{#each props.pubkeys.slice(0, 15) as pubkey (pubkey)}
|
||||
{#each props.pubkeys.toSorted().slice(0, 15) as pubkey (pubkey)}
|
||||
<div class="z-feature -mr-3 inline-block">
|
||||
<ProfileCircle class="h-8 w-8 bg-base-300" {pubkey} {...props} />
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
<script lang="ts">
|
||||
import {goto} from "$app/navigation"
|
||||
import {removeNil} from "@welshman/lib"
|
||||
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
|
||||
import {
|
||||
session,
|
||||
userFollows,
|
||||
deriveUserWotScore,
|
||||
deriveHandleForPubkey,
|
||||
displayHandle,
|
||||
deriveProfile,
|
||||
deriveProfileDisplay,
|
||||
} from "@welshman/app"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Avatar from "@lib/components/Avatar.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Avatar from "@lib/components/Avatar.svelte"
|
||||
import WotScore from "@lib/components/WotScore.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import Profile from "@app/components/Profile.svelte"
|
||||
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
||||
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
||||
import ChatEnable from "@app/components/ChatEnable.svelte"
|
||||
import {canDecrypt, pubkeyLink} from "@app/state"
|
||||
import {pushModal} from "@app/modal"
|
||||
@@ -30,41 +20,17 @@
|
||||
|
||||
const {pubkey, url}: Props = $props()
|
||||
|
||||
const relays = removeNil([url])
|
||||
const profile = deriveProfile(pubkey, relays)
|
||||
const display = deriveProfileDisplay(pubkey, relays)
|
||||
const handle = deriveHandleForPubkey(pubkey)
|
||||
const score = deriveUserWotScore(pubkey)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const chatPath = makeChatPath([pubkey])
|
||||
|
||||
const openChat = () => ($canDecrypt ? goto(chatPath) : pushModal(ChatEnable, {next: chatPath}))
|
||||
|
||||
const following = $derived(
|
||||
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey),
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="column gap-4">
|
||||
<div class="flex max-w-full gap-3">
|
||||
<span class="py-1">
|
||||
<Avatar src={$profile?.picture} size={10} />
|
||||
</span>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-bold overflow-hidden text-ellipsis">
|
||||
{$display}
|
||||
</span>
|
||||
<WotScore score={$score} active={following} />
|
||||
</div>
|
||||
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
|
||||
{$handle ? displayHandle($handle) : displayPubkey(pubkey)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Profile showPubkey avatarSize={14} {pubkey} {url} />
|
||||
<ProfileInfo {pubkey} {url} />
|
||||
<ProfileBadges {pubkey} {url} />
|
||||
<ModalFooter>
|
||||
<Button onclick={back} class="hidden md:btn md:btn-link">
|
||||
<Icon icon="alt-arrow-left" />
|
||||
@@ -72,8 +38,8 @@
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Link external href={pubkeyLink(pubkey)} class="btn btn-neutral">
|
||||
<Icon icon="user-circle" />
|
||||
See Complete Profile
|
||||
<Avatar src="/coracle.png" />
|
||||
Open in Coracle
|
||||
</Link>
|
||||
<Button onclick={openChat} class="btn btn-primary">
|
||||
<Icon icon="letter" />
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
</script>
|
||||
|
||||
{#if $profile}
|
||||
<Content event={{content: $profile.about, tags: []}} hideMediaAtDepth={0} />
|
||||
<Content event={{content: $profile.about || "", tags: []}} hideMediaAtDepth={0} />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {load} from "@welshman/net"
|
||||
import {NOTE} from "@welshman/util"
|
||||
import {fly} from "@lib/transition"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import NoteItem from "@app/components/NoteItem.svelte"
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
pubkey: string
|
||||
limit?: number
|
||||
fallback?: Snippet
|
||||
}
|
||||
|
||||
const {url, pubkey, limit = 1, fallback}: Props = $props()
|
||||
|
||||
const events = load({
|
||||
relays: [url],
|
||||
filters: [{authors: [pubkey], kinds: [NOTE], limit}],
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="col-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
{#await events}
|
||||
<p class="center my-12 flex">
|
||||
<Spinner loading />
|
||||
</p>
|
||||
{:then events}
|
||||
{#each events as event (event.id)}
|
||||
<div in:fly>
|
||||
<NoteItem {url} {event} />
|
||||
</div>
|
||||
{:else}
|
||||
{@render fallback?.()}
|
||||
{/each}
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
@@ -8,13 +9,14 @@
|
||||
type Props = {
|
||||
pubkey: string
|
||||
url?: string
|
||||
unstyled?: boolean
|
||||
}
|
||||
|
||||
const {pubkey, url}: Props = $props()
|
||||
const {pubkey, url, unstyled}: Props = $props()
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
|
||||
</script>
|
||||
|
||||
<Button onclick={preventDefault(openProfile)} class="link-content">
|
||||
<Button onclick={preventDefault(openProfile)} class={cx({"link-content": !unstyled})}>
|
||||
@<ProfileName {pubkey} {url} />
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {writable} from "svelte/store"
|
||||
import type {Writable} from "svelte/store"
|
||||
import {type Instance} from "tippy.js"
|
||||
import {append, remove, uniq} from "@welshman/lib"
|
||||
import {profileSearch} from "@welshman/app"
|
||||
@@ -15,11 +16,10 @@
|
||||
interface Props {
|
||||
value: string[]
|
||||
autofocus?: boolean
|
||||
term?: Writable<string>
|
||||
}
|
||||
|
||||
let {value = $bindable(), autofocus = false}: Props = $props()
|
||||
|
||||
const term = writable("")
|
||||
let {value = $bindable(), term = writable(""), autofocus = false}: Props = $props()
|
||||
|
||||
const search = (term: string) => $profileSearch.searchValues(term)
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
let instance: any = $state()
|
||||
|
||||
$effect(() => {
|
||||
// @ts-ignore
|
||||
oninput?.($term)
|
||||
|
||||
if ($term) {
|
||||
popover?.show()
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
import {getMembershipUrls, membershipsByPubkey} from "@app/state"
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
const {pubkey}: Props = $props()
|
||||
|
||||
const spaceUrls = $derived(getMembershipUrls($membershipsByPubkey.get(pubkey)))
|
||||
|
||||
const back = () => history.back()
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each spaceUrls as url (url)}
|
||||
<div class="card2 bg-alt flex flex-row items-center gap-2">
|
||||
<div class="flex-shrink-0">
|
||||
<SpaceAvatar {url} />
|
||||
</div>
|
||||
<div class="flex flex-grow flex-col">
|
||||
<RelayName {url} />
|
||||
<div class="text-sm opacity-75">
|
||||
{url}
|
||||
</div>
|
||||
</div>
|
||||
<Link class="btn btn-primary" href={makeSpacePath(url)}>
|
||||
Go to space
|
||||
<Icon icon="alt-arrow-right" />
|
||||
</Link>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card2 bg-alt text-center">
|
||||
<p class="opacity-75">No spaces found for this user</p>
|
||||
</div>
|
||||
{/each}
|
||||
<ModalFooter>
|
||||
<Button onclick={back} class="hidden md:btn md:btn-link">
|
||||
<Icon icon="alt-arrow-left" />
|
||||
Go back
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
@@ -2,7 +2,7 @@
|
||||
import {goto} from "$app/navigation"
|
||||
import {randomId} from "@welshman/lib"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import {deriveRelay} from "@welshman/app"
|
||||
import {deriveRelay, getThunkError} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
@@ -10,8 +10,8 @@
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {hasNip29} from "@app/state"
|
||||
import {addRoomMembership, nip29, getThunkError} from "@app/commands"
|
||||
import {hasNip29, loadChannel} from "@app/state"
|
||||
import {createRoom, editRoom, joinRoom} from "@app/commands"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
@@ -23,27 +23,26 @@
|
||||
const back = () => history.back()
|
||||
|
||||
const tryCreate = async () => {
|
||||
if (hasNip29($relay)) {
|
||||
const createMessage = await getThunkError(nip29.createRoom(url, room))
|
||||
const createMessage = await getThunkError(createRoom(url, room))
|
||||
|
||||
if (createMessage && !createMessage.match(/^duplicate:|already a member/)) {
|
||||
return pushToast({theme: "error", message: createMessage})
|
||||
}
|
||||
|
||||
const editMessage = await getThunkError(nip29.editMeta(url, room, {name}))
|
||||
|
||||
if (editMessage) {
|
||||
return pushToast({theme: "error", message: editMessage})
|
||||
}
|
||||
|
||||
const joinMessage = await getThunkError(nip29.joinRoom(url, room))
|
||||
|
||||
if (joinMessage && !joinMessage.includes("already")) {
|
||||
return pushToast({theme: "error", message: joinMessage})
|
||||
}
|
||||
if (createMessage && !createMessage.match(/^duplicate:|already a member/)) {
|
||||
return pushToast({theme: "error", message: createMessage})
|
||||
}
|
||||
|
||||
addRoomMembership(url, room, name)
|
||||
const editMessage = await getThunkError(editRoom(url, room, {name}))
|
||||
|
||||
if (editMessage) {
|
||||
return pushToast({theme: "error", message: editMessage})
|
||||
}
|
||||
|
||||
const joinMessage = await getThunkError(joinRoom(url, room))
|
||||
|
||||
if (joinMessage && !joinMessage.includes("already")) {
|
||||
return pushToast({theme: "error", message: joinMessage})
|
||||
}
|
||||
|
||||
await loadChannel(url, room)
|
||||
|
||||
goto(makeSpacePath(url, room))
|
||||
}
|
||||
|
||||
@@ -72,23 +71,30 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Room Name</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon="hashtag" />
|
||||
<input bind:value={name} class="grow" type="text" />
|
||||
</label>
|
||||
{/snippet}
|
||||
</Field>
|
||||
{#if hasNip29($relay)}
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Room Name</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon="hashtag" />
|
||||
<input bind:value={name} class="grow" type="text" />
|
||||
</label>
|
||||
{/snippet}
|
||||
</Field>
|
||||
{:else}
|
||||
<p class="bg-alt card2 row-2">
|
||||
<Icon icon="danger" />
|
||||
This relay does not support creating rooms.
|
||||
</p>
|
||||
{/if}
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon="alt-arrow-left" />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={!name || loading}>
|
||||
<Button type="submit" class="btn btn-primary" disabled={!name || loading || !hasNip29($relay)}>
|
||||
<Spinner {loading}>Create Room</Spinner>
|
||||
<Icon icon="alt-arrow-right" />
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {encrypt} from "nostr-tools/nip49"
|
||||
import {hexToBytes} from "@noble/hashes/utils"
|
||||
import {hexToBytes} from "@welshman/lib"
|
||||
import {makeSecret} from "@welshman/signer"
|
||||
import {preventDefault, downloadText} from "@lib/html"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import {displayUrl} from "@welshman/lib"
|
||||
import {Pool, AuthStatus} from "@welshman/net"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import SpaceJoinConfirm, {confirmSpaceJoin} from "@app/components/SpaceJoinConfirm.svelte"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {attemptRelayAccess} from "@app/commands"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const joinRelay = async () => {
|
||||
const error = await attemptRelayAccess(url, claim)
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error, timeout: 30_000})
|
||||
}
|
||||
|
||||
const socket = Pool.get().get(url)
|
||||
|
||||
if (socket.auth.status === AuthStatus.None) {
|
||||
pushModal(SpaceJoinConfirm, {url}, {replaceState: true})
|
||||
} else {
|
||||
await confirmSpaceJoin(url)
|
||||
}
|
||||
}
|
||||
|
||||
const join = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
await joinRelay()
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
let claim = $state("")
|
||||
let loading = $state(false)
|
||||
</script>
|
||||
|
||||
<form class="column gap-4" onsubmit={preventDefault(join)}>
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Request Access</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Enter an invite code below to request access to {displayUrl(url)}.</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Invite code*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon="link-round" />
|
||||
<input bind:value={claim} class="grow" type="text" />
|
||||
</label>
|
||||
{/snippet}
|
||||
</Field>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon="alt-arrow-left" />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||
<Spinner {loading}>Join Space</Spinner>
|
||||
<Icon icon="alt-arrow-right" />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
@@ -1,49 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import {parse, renderAsHtml} from "@welshman/content"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import {ucFirst} from "@lib/util"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {clearModals} from "@app/modal"
|
||||
import {attemptRelayAccess} from "@app/commands"
|
||||
import SpaceAccessRequest from "@app/components/SpaceAccessRequest.svelte"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
const {url, error} = $props()
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const joinRelay = async () => {
|
||||
const error = await attemptRelayAccess(url)
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
}
|
||||
|
||||
pushToast({
|
||||
message: "You have successfully joined the space!",
|
||||
})
|
||||
|
||||
clearModals()
|
||||
}
|
||||
|
||||
const join = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
await joinRelay()
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
let loading = $state(false)
|
||||
const requestAccess = () => pushModal(SpaceAccessRequest, {url})
|
||||
</script>
|
||||
|
||||
<form class="column gap-4" onsubmit={preventDefault(join)}>
|
||||
<form class="column gap-4" onsubmit={preventDefault(requestAccess)}>
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
<div>Access Error</div>
|
||||
@@ -63,8 +37,8 @@
|
||||
<Icon icon="alt-arrow-left" />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||
<Spinner {loading}>Request Access</Spinner>
|
||||
<Button type="submit" class="btn btn-primary">
|
||||
Request Access
|
||||
<Icon icon="alt-arrow-right" />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -28,13 +28,19 @@
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<p>
|
||||
<Link class="text-primary" external href="https://relay.tools">relay.tools</Link> is a third-party
|
||||
service that allows anyone to run their own relay for use with {PLATFORM_NAME}, or any other
|
||||
<Link class="link" external href="https://relay.tools">relay.tools</Link> is a third-party service
|
||||
that allows anyone to run their own relay for use with {PLATFORM_NAME}, or any other
|
||||
nostr-compatible app.
|
||||
</p>
|
||||
<p>
|
||||
Once you've created a relay of your own, come back here to link {PLATFORM_NAME} with your new relay.
|
||||
</p>
|
||||
<p>
|
||||
Alternatively, you can
|
||||
<Link external class="link" href="https://github.com/coracle-social/frith"
|
||||
>run your own community relay</Link
|
||||
>.
|
||||
</p>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon="alt-arrow-left" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {tryCatch} from "@welshman/lib"
|
||||
import {tryCatch, first, removeNil} from "@welshman/lib"
|
||||
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
|
||||
import {Pool, AuthStatus} from "@welshman/net"
|
||||
import {preventDefault} from "@lib/html"
|
||||
@@ -17,21 +17,36 @@
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const joinRelay = async (invite: string) => {
|
||||
const [raw, claim] = invite.split("|")
|
||||
const url = normalizeRelayUrl(raw)
|
||||
const error = await attemptRelayAccess(url, claim)
|
||||
const joinRelay = async () => {
|
||||
const promises: Promise<string | undefined>[] = []
|
||||
|
||||
const [rawUrl, rawClaim] = url.split("|")
|
||||
const normalizedUrl = normalizeRelayUrl(rawUrl)
|
||||
|
||||
if (claim) {
|
||||
promises.push(attemptRelayAccess(normalizedUrl, claim))
|
||||
}
|
||||
|
||||
if (rawClaim) {
|
||||
promises.push(attemptRelayAccess(normalizedUrl, rawClaim))
|
||||
}
|
||||
|
||||
if (promises.length === 0) {
|
||||
promises.push(attemptRelayAccess(normalizedUrl, ""))
|
||||
}
|
||||
|
||||
const error = first(removeNil(await Promise.all(promises)))
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error, timeout: 30_000})
|
||||
}
|
||||
|
||||
const socket = Pool.get().get(url)
|
||||
const socket = Pool.get().get(normalizedUrl)
|
||||
|
||||
if (socket.auth.status === AuthStatus.None) {
|
||||
pushModal(SpaceJoinConfirm, {url}, {replaceState: true})
|
||||
pushModal(SpaceJoinConfirm, {url: normalizedUrl}, {replaceState: true})
|
||||
} else {
|
||||
await confirmSpaceJoin(url)
|
||||
await confirmSpaceJoin(normalizedUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,13 +54,14 @@
|
||||
loading = true
|
||||
|
||||
try {
|
||||
await joinRelay(url)
|
||||
await joinRelay()
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
let url = $state("")
|
||||
let claim = $state("")
|
||||
let loading = $state(false)
|
||||
|
||||
const linkIsValid = $derived(
|
||||
@@ -59,12 +75,12 @@
|
||||
<div>Join a Space</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Enter an invite code below to join an existing space.</div>
|
||||
<div>Enter a relay URL below to join an existing space.</div>
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Invite code*</p>
|
||||
<p>Relay URL*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
@@ -74,11 +90,25 @@
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<p>
|
||||
You can also directly join any relay by entering its URL here.
|
||||
Enter the URL of the relay that hosts the space you'd like to join.
|
||||
<Button class="link" onclick={() => pushModal(InfoRelay)}>What is a relay?</Button>
|
||||
</p>
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Invite Code (optional)</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon="ticket" />
|
||||
<input bind:value={claim} class="grow" type="text" />
|
||||
</label>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<p>If you have an invite code, enter it here to get access.</p>
|
||||
{/snippet}
|
||||
</Field>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon="alt-arrow-left" />
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import {deriveRelay} from "@welshman/app"
|
||||
import {fade} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import RoomCreate from "@app/components/RoomCreate.svelte"
|
||||
import ChannelName from "@app/components/ChannelName.svelte"
|
||||
import {makeThreadPath, makeCalendarPath, makeRoomPath} from "@app/routes"
|
||||
import {
|
||||
hasNip29,
|
||||
deriveUserRooms,
|
||||
deriveOtherRooms,
|
||||
makeChannelId,
|
||||
channelsById,
|
||||
} from "@app/state"
|
||||
import {notifications} from "@app/notifications"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
const relay = deriveRelay(url)
|
||||
const userRooms = deriveUserRooms(url)
|
||||
const otherRooms = deriveOtherRooms(url)
|
||||
const threadsPath = makeThreadPath(url)
|
||||
const calendarPath = makeCalendarPath(url)
|
||||
|
||||
const addRoom = () => pushModal(RoomCreate, {url})
|
||||
|
||||
const filteredRooms = $derived(() => {
|
||||
if (!term) return [...$userRooms, ...$otherRooms]
|
||||
|
||||
const query = term.toLowerCase()
|
||||
const allRooms = [...$userRooms, ...$otherRooms]
|
||||
|
||||
return allRooms.filter(room => {
|
||||
const channel = $channelsById.get(makeChannelId(url, room))
|
||||
const roomName = channel?.name || room
|
||||
return roomName.toLowerCase().includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
let term = $state("")
|
||||
</script>
|
||||
|
||||
<div class="card2 bg-alt md:hidden">
|
||||
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
|
||||
<Icon icon="compass-big" />
|
||||
Quick Links
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Link href={threadsPath} class="btn btn-primary w-full justify-start">
|
||||
<div class="relative flex items-center gap-2">
|
||||
<Icon icon="notes-minimalistic" />
|
||||
Threads
|
||||
{#if $notifications.has(threadsPath)}
|
||||
<div
|
||||
class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-primary-content"
|
||||
transition:fade>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Link>
|
||||
<Link href={calendarPath} class="btn btn-secondary w-full justify-start">
|
||||
<div class="relative flex items-center gap-2">
|
||||
<Icon icon="calendar-minimalistic" />
|
||||
Calendar
|
||||
{#if $notifications.has(calendarPath)}
|
||||
<div
|
||||
class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-primary-content"
|
||||
transition:fade>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Link>
|
||||
{#if $userRooms.length + $otherRooms.length > 10}
|
||||
<label class="input input-sm input-bordered flex flex-grow items-center gap-2">
|
||||
<Icon icon="magnifer" size={4} />
|
||||
<input bind:value={term} class="grow" type="text" placeholder="Search rooms..." />
|
||||
</label>
|
||||
{/if}
|
||||
{#each filteredRooms() as room (room)}
|
||||
{@const roomPath = makeRoomPath(url, room)}
|
||||
{@const channel = $channelsById.get(makeChannelId(url, room))}
|
||||
<Link href={roomPath} class="btn btn-neutral btn-sm relative w-full justify-start">
|
||||
<div class="flex min-w-0 items-center gap-2 overflow-hidden text-nowrap">
|
||||
{#if channel?.closed || channel?.private}
|
||||
<Icon icon="lock" size={4} />
|
||||
{:else}
|
||||
<Icon icon="hashtag" />
|
||||
{/if}
|
||||
<ChannelName {url} {room} />
|
||||
</div>
|
||||
{#if $notifications.has(roomPath)}
|
||||
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary" transition:fade></div>
|
||||
{/if}
|
||||
</Link>
|
||||
{/each}
|
||||
{#if hasNip29($relay)}
|
||||
<Button onclick={addRoom} class="btn btn-neutral btn-sm w-full justify-start">
|
||||
<Icon icon="add-circle" />
|
||||
Create Room
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import {derived} from "svelte/store"
|
||||
import {groupBy, ago, MONTH, first, last, uniq, avg, overlappingPairs} from "@welshman/lib"
|
||||
import {formatTimestamp} from "@welshman/lib"
|
||||
import {MESSAGE, getTagValue} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||
import {deriveEventsForUrl} from "@app/state"
|
||||
import {goToEvent} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
const since = ago(MONTH)
|
||||
const messages = deriveEventsForUrl(url, [{kinds: [MESSAGE], since}])
|
||||
|
||||
const conversations = derived(messages, $messages => {
|
||||
const convs = []
|
||||
|
||||
for (const [room, messages] of groupBy(e => getTagValue("h", e.tags), $messages).entries()) {
|
||||
const avgTime = avg(overlappingPairs(messages).map(([a, b]) => a.created_at - b.created_at))
|
||||
const groups: TrustedEvent[][] = []
|
||||
const group: TrustedEvent[] = []
|
||||
|
||||
// Group conversations by time between messages
|
||||
let prevCreatedAt = messages[0].created_at
|
||||
for (const message of messages) {
|
||||
if (prevCreatedAt - message.created_at < avgTime) {
|
||||
group.push(message)
|
||||
} else {
|
||||
groups.push(group.splice(0))
|
||||
}
|
||||
|
||||
prevCreatedAt = message.created_at
|
||||
}
|
||||
|
||||
if (group.length > 0) {
|
||||
groups.push(group.splice(0))
|
||||
}
|
||||
|
||||
// Convert each group into a conversation
|
||||
for (const events of groups) {
|
||||
if (events.length < 2) {
|
||||
continue
|
||||
}
|
||||
|
||||
const latest = first(events)!
|
||||
const earliest = last(events)!
|
||||
const participants = uniq(events.map(msg => msg.pubkey))
|
||||
|
||||
convs.push({room, events, latest, earliest, participants})
|
||||
}
|
||||
}
|
||||
|
||||
return convs
|
||||
})
|
||||
|
||||
const viewMore = () => {
|
||||
limit += 3
|
||||
}
|
||||
|
||||
let limit = $state(3)
|
||||
</script>
|
||||
|
||||
<div class="card2 bg-alt">
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<Icon icon="chat-round" />
|
||||
Recent Conversations
|
||||
</h3>
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if $conversations.length === 0}
|
||||
<div class="py-8 text-center opacity-70">
|
||||
<p>No recent conversations</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each $conversations.slice(0, limit) as { room, events, latest, earliest, participants } (latest.id)}
|
||||
<Button class="card2 bg-alt" onclick={() => goToEvent(earliest)}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<ProfileCircle pubkey={earliest.pubkey} size={10} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">
|
||||
<span class="font-medium text-blue-400">#{room}</span>
|
||||
<span class="opacity-50">•</span>
|
||||
<span>{formatTimestamp(earliest.created_at)}</span>
|
||||
</div>
|
||||
<Content minimalQuote minLength={100} maxLength={400} event={earliest} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-13 flex items-center justify-between">
|
||||
<div class="flex gap-1">
|
||||
<Icon icon="alt-arrow-left" />
|
||||
<span class="text-sm opacity-70">
|
||||
{events.length} messages
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ProfileCircles pubkeys={participants} size={6} />
|
||||
<span class="text-sm opacity-70">
|
||||
{participants.length} participants
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button class="card2 bg-alt" onclick={() => goToEvent(latest)}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">
|
||||
<ProfileCircle pubkey={latest.pubkey} size={5} />
|
||||
<span class="font-medium">Latest reply:</span>
|
||||
</div>
|
||||
<span class="text-xs opacity-50">
|
||||
{formatTimestamp(latest.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<Content minimalQuote minLength={100} maxLength={400} event={latest} />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Button>
|
||||
{/each}
|
||||
{#if $conversations.length > limit}
|
||||
<Button class="btn btn-primary" onclick={viewMore}>
|
||||
View more conversations
|
||||
<Icon icon="alt-arrow-down" />
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import {deriveRelay} from "@welshman/app"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import SocketStatusIndicator from "@lib/components/SocketStatusIndicator.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
|
||||
const relay = deriveRelay(url)
|
||||
const owner = $derived($relay?.profile?.pubkey)
|
||||
</script>
|
||||
|
||||
<div class="card2 bg-alt flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<Icon icon="server" />
|
||||
Relay Details
|
||||
</h3>
|
||||
<SocketStatusIndicator {url} />
|
||||
</div>
|
||||
{#if $relay?.profile}
|
||||
{@const {software, version, supported_nips, limitation} = $relay.profile}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#if owner}
|
||||
<div class="badge badge-neutral">
|
||||
<span class="ellipsize">Administrator: <ProfileLink unstyled pubkey={owner} /></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $relay?.profile?.contact}
|
||||
<div class="badge badge-neutral">
|
||||
<span class="ellipsize">Contact: {$relay.profile.contact}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if software}
|
||||
<div class="badge badge-neutral">
|
||||
<span class="ellipsize">Software: {software}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if version}
|
||||
<div class="badge badge-neutral">
|
||||
<span class="ellipsize">Version: {version}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if Array.isArray(supported_nips)}
|
||||
<p class="badge badge-neutral">
|
||||
<span class="ellipsize">Supported NIPs: {supported_nips.join(", ")}</span>
|
||||
</p>
|
||||
{/if}
|
||||
{#if limitation?.auth_required}
|
||||
<p class="badge badge-warning">
|
||||
<span class="ellipsize">Auth Required</span>
|
||||
</p>
|
||||
{/if}
|
||||
{#if limitation?.payment_required}
|
||||
<p class="badge badge-warning">
|
||||
<span class="ellipsize">Payment Required</span>
|
||||
</p>
|
||||
{/if}
|
||||
{#if limitation?.min_pow_difficulty}
|
||||
<p class="badge badge-warning">
|
||||
<span class="ellipsize">Min PoW: {limitation?.min_pow_difficulty}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -10,7 +10,7 @@
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {GENERAL, tagRoom, PROTECTED} from "@app/state"
|
||||
import {PROTECTED} from "@app/state"
|
||||
import {makeEditor} from "@app/editor"
|
||||
|
||||
const {url} = $props()
|
||||
@@ -41,12 +41,7 @@
|
||||
})
|
||||
}
|
||||
|
||||
const tags = [
|
||||
...ed.storage.nostr.getEditorTags(),
|
||||
tagRoom(GENERAL, url),
|
||||
["title", title],
|
||||
PROTECTED,
|
||||
]
|
||||
const tags = [...ed.storage.nostr.getEditorTags(), ["title", title], PROTECTED]
|
||||
|
||||
publishThunk({
|
||||
relays: [url],
|
||||
|
||||
@@ -15,19 +15,21 @@
|
||||
|
||||
<script lang="ts">
|
||||
import {clamp} from "@welshman/lib"
|
||||
import {pubkey, getFollows, deriveUserWotScore} from "@welshman/app"
|
||||
|
||||
interface Props {
|
||||
score: any
|
||||
max?: number
|
||||
active?: boolean
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
const {score, max = 100, active = false}: Props = $props()
|
||||
const {pubkey: target}: Props = $props()
|
||||
|
||||
const max = 100
|
||||
const radius = 6
|
||||
const center = radius + 1
|
||||
|
||||
const normalizedScore = $derived(clamp([0, max], score) / max)
|
||||
const score = deriveUserWotScore(target)
|
||||
const active = $derived(getFollows($pubkey!).includes(target))
|
||||
const normalizedScore = $derived(clamp([0, max], $score) / max)
|
||||
const dashOffset = $derived(100 - 44 * normalizedScore)
|
||||
const style = $derived(`transform: rotate(${135 - normalizedScore * 180}deg)`)
|
||||
const stroke = $derived(active ? "var(--primary)" : "var(--base-content)")
|
||||
@@ -1,14 +1,8 @@
|
||||
<script lang="ts">
|
||||
import {removeNil} from "@welshman/lib"
|
||||
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
|
||||
import {
|
||||
userFollows,
|
||||
deriveUserWotScore,
|
||||
deriveHandleForPubkey,
|
||||
displayHandle,
|
||||
deriveProfileDisplay,
|
||||
} from "@welshman/app"
|
||||
import WotScore from "@lib/components/WotScore.svelte"
|
||||
import {displayPubkey} from "@welshman/util"
|
||||
import {deriveHandleForPubkey, displayHandle, deriveProfileDisplay} from "@welshman/app"
|
||||
import WotScore from "@app/components/WotScore.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
|
||||
type Props = {
|
||||
@@ -21,9 +15,6 @@
|
||||
const pubkey = value
|
||||
const profileDisplay = deriveProfileDisplay(pubkey, removeNil([url]))
|
||||
const handle = deriveHandleForPubkey(pubkey)
|
||||
const score = deriveUserWotScore(pubkey)
|
||||
|
||||
const following = $derived(getPubkeyTagValues(getListTags($userFollows)).includes(pubkey))
|
||||
</script>
|
||||
|
||||
<div class="flex max-w-full gap-3">
|
||||
@@ -35,7 +26,7 @@
|
||||
<div class="text-bold overflow-hidden text-ellipsis text-base">
|
||||
{$profileDisplay}
|
||||
</div>
|
||||
<WotScore score={$score} active={following} />
|
||||
<WotScore {pubkey} />
|
||||
</div>
|
||||
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
|
||||
{$handle ? displayHandle($handle) : displayPubkey(pubkey)}
|
||||
|
||||
@@ -1,71 +1,33 @@
|
||||
import {mount} from "svelte"
|
||||
import type {Writable} from "svelte/store"
|
||||
import {get} from "svelte/store"
|
||||
import type {StampedEvent} from "@welshman/util"
|
||||
import {makeEvent, getTagValues, getListTags, BLOSSOM_AUTH} from "@welshman/util"
|
||||
import {simpleCache, normalizeUrl, removeNil, now} from "@welshman/lib"
|
||||
import {sha256} from "@welshman/lib"
|
||||
import {
|
||||
getTagValues,
|
||||
encryptFile,
|
||||
uploadBlob,
|
||||
makeBlossomAuthEvent,
|
||||
getListTags,
|
||||
} from "@welshman/util"
|
||||
import {Router} from "@welshman/router"
|
||||
import {Nip01Signer} from "@welshman/signer"
|
||||
import {signer, profileSearch, userBlossomServers} from "@welshman/app"
|
||||
import type {FileAttributes} from "@welshman/editor"
|
||||
import {Editor, MentionSuggestion, WelshmanExtension} from "@welshman/editor"
|
||||
import {makeMentionNodeView} from "./MentionNodeView"
|
||||
import ProfileSuggestion from "./ProfileSuggestion.svelte"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
export const hasBlossomSupport = simpleCache(async ([url]: [string]) => {
|
||||
const $signer = signer.get()
|
||||
const headers: Record<string, string> = {
|
||||
"X-Content-Type": "text/plain",
|
||||
"X-Content-Length": "1",
|
||||
"X-SHA-256": "73cb3858a687a8494ca3323053016282f3dad39d42cf62ca4e79dda2aac7d9ac",
|
||||
}
|
||||
|
||||
try {
|
||||
if ($signer) {
|
||||
const event = await signer.get().sign(
|
||||
makeEvent(BLOSSOM_AUTH, {
|
||||
tags: [
|
||||
["t", "upload"],
|
||||
["server", url],
|
||||
["expiration", String(now() + 30)],
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
headers.Authorization = `Nostr ${btoa(JSON.stringify(event))}`
|
||||
}
|
||||
|
||||
const res = await fetch(normalizeUrl(url) + "/upload", {method: "head", headers})
|
||||
|
||||
return res.status === 200
|
||||
} catch (e) {
|
||||
if (!String(e).includes("Failed to fetch")) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
export const getUploadUrl = async (spaceUrl?: string) => {
|
||||
export const getBlossomServer = () => {
|
||||
const userUrls = getTagValues("server", getListTags(userBlossomServers.get()))
|
||||
const allUrls = removeNil([spaceUrl, ...userUrls])
|
||||
|
||||
for (let url of allUrls) {
|
||||
url = url.replace(/^ws/, "http")
|
||||
|
||||
if (await hasBlossomSupport(url)) {
|
||||
return url
|
||||
}
|
||||
for (const url of userUrls) {
|
||||
return url.replace(/^ws/, "http")
|
||||
}
|
||||
|
||||
return "https://cdn.satellite.earth"
|
||||
}
|
||||
|
||||
export const signWithAssert = async (template: StampedEvent) => {
|
||||
const event = await signer.get().sign(template)
|
||||
|
||||
return event!
|
||||
}
|
||||
|
||||
export const makeEditor = async ({
|
||||
aggressive = false,
|
||||
autofocus = false,
|
||||
@@ -76,7 +38,6 @@ export const makeEditor = async ({
|
||||
submit,
|
||||
uploading,
|
||||
wordCount,
|
||||
disableFileUpload,
|
||||
}: {
|
||||
aggressive?: boolean
|
||||
autofocus?: boolean
|
||||
@@ -87,7 +48,6 @@ export const makeEditor = async ({
|
||||
submit: () => void
|
||||
uploading?: Writable<boolean>
|
||||
wordCount?: Writable<number>
|
||||
disableFileUpload?: boolean
|
||||
}) => {
|
||||
return new Editor({
|
||||
content,
|
||||
@@ -96,9 +56,6 @@ export const makeEditor = async ({
|
||||
extensions: [
|
||||
WelshmanExtension.configure({
|
||||
submit,
|
||||
sign: signWithAssert,
|
||||
defaultUploadType: "blossom",
|
||||
defaultUploadUrl: await getUploadUrl(url),
|
||||
extensions: {
|
||||
placeholder: {
|
||||
config: {
|
||||
@@ -110,18 +67,81 @@ export const makeEditor = async ({
|
||||
aggressive,
|
||||
},
|
||||
},
|
||||
fileUpload: disableFileUpload
|
||||
? false
|
||||
: {
|
||||
config: {
|
||||
onDrop() {
|
||||
uploading?.set(true)
|
||||
},
|
||||
onComplete() {
|
||||
uploading?.set(false)
|
||||
},
|
||||
},
|
||||
fileUpload: {
|
||||
config: {
|
||||
upload: async (attrs: FileAttributes) => {
|
||||
let file: Blob = attrs.file
|
||||
|
||||
if (!file.type.match("image/(webp|gif)")) {
|
||||
const {default: Compressor} = await import("compressorjs")
|
||||
|
||||
file = await new Promise((resolve, _reject) => {
|
||||
new Compressor(file, {
|
||||
maxWidth: 1024,
|
||||
maxHeight: 1024,
|
||||
convertSize: 2 * 1024 * 1024,
|
||||
success: resolve,
|
||||
error: e => {
|
||||
// Non-images break compressor
|
||||
if (e.toString().includes("File or Blob")) {
|
||||
return resolve(file)
|
||||
}
|
||||
|
||||
_reject(e)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const {ciphertext, key, nonce, algorithm} = await encryptFile(file)
|
||||
const tags = [
|
||||
["decryption-key", key],
|
||||
["decryption-nonce", nonce],
|
||||
["encryption-algorithm", algorithm],
|
||||
]
|
||||
|
||||
file = new File([new Blob([ciphertext])], attrs.file.name, {type: attrs.file.type})
|
||||
|
||||
const server = getBlossomServer()
|
||||
const hashes = [await sha256(await file.arrayBuffer())]
|
||||
const $signer = signer.get() || Nip01Signer.ephemeral()
|
||||
const authTemplate = makeBlossomAuthEvent({action: "upload", server, hashes})
|
||||
const authEvent = await $signer.sign(authTemplate)
|
||||
|
||||
try {
|
||||
const res = await uploadBlob(server, file, {authEvent})
|
||||
let {uploaded, url, ...task} = await res.json()
|
||||
|
||||
if (!uploaded) {
|
||||
return {error: "Server refused to process the file"}
|
||||
}
|
||||
|
||||
// Always append file extension if missing
|
||||
if (new URL(url).pathname.split(".").length === 1) {
|
||||
url += "." + attrs.file.type.split("/")[1]
|
||||
}
|
||||
|
||||
const result = {...task, tags, url}
|
||||
|
||||
return {result}
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
return {error: e.toString()}
|
||||
}
|
||||
},
|
||||
onDrop() {
|
||||
uploading?.set(true)
|
||||
},
|
||||
onComplete() {
|
||||
uploading?.set(false)
|
||||
},
|
||||
onUploadError(currentEditor, task) {
|
||||
currentEditor.commands.removeFailedUploads()
|
||||
pushToast({theme: "error", message: "Failed to upload file"})
|
||||
uploading?.set(false)
|
||||
},
|
||||
},
|
||||
},
|
||||
nprofile: {
|
||||
extend: {
|
||||
addNodeView: () => makeMentionNodeView(url),
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import type {Page} from "@sveltejs/kit"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {goto} from "$app/navigation"
|
||||
import {nthEq, sleep} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {tracker} from "@welshman/app"
|
||||
import {scrollToEvent} from "@lib/html"
|
||||
import {identity} from "@welshman/lib"
|
||||
import {makeChatId, decodeRelay, encodeRelay, userRoomsByUrl} from "@app/state"
|
||||
import {
|
||||
getTagValue,
|
||||
DIRECT_MESSAGE,
|
||||
DIRECT_MESSAGE_FILE,
|
||||
MESSAGE,
|
||||
THREAD,
|
||||
EVENT_TIME,
|
||||
} from "@welshman/util"
|
||||
import {makeChatId, entityLink, decodeRelay, encodeRelay, userRoomsByUrl, ROOM} from "@app/state"
|
||||
|
||||
export const makeSpacePath = (url: string, ...extra: (string | undefined)[]) => {
|
||||
let path = `/spaces/${encodeRelay(url)}`
|
||||
@@ -46,3 +60,54 @@ export const getPrimaryNavItemIndex = ($page: Page) => {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export const goToMessage = async (url: string, room: string | undefined, id: string) => {
|
||||
await goto(room ? makeRoomPath(url, room) : makeSpacePath(url, "chat"))
|
||||
await sleep(300)
|
||||
|
||||
return scrollToEvent(id)
|
||||
}
|
||||
|
||||
export const goToEvent = async (event: TrustedEvent) => {
|
||||
if (event.kind === DIRECT_MESSAGE || event.kind === DIRECT_MESSAGE_FILE) {
|
||||
return await scrollToEvent(event.id)
|
||||
}
|
||||
|
||||
const urls = Array.from(tracker.getRelays(event.id))
|
||||
const room = getTagValue(ROOM, event.tags)
|
||||
|
||||
if (urls.length > 0) {
|
||||
const url = urls[0]
|
||||
|
||||
if (event.kind === THREAD) {
|
||||
return goto(makeThreadPath(url, event.id))
|
||||
}
|
||||
|
||||
if (event.kind === EVENT_TIME) {
|
||||
return goto(makeCalendarPath(url, event.id))
|
||||
}
|
||||
|
||||
if (event.kind === MESSAGE) {
|
||||
return goToMessage(url, room, event.id)
|
||||
}
|
||||
|
||||
const kind = event.tags.find(nthEq(0, "K"))?.[1]
|
||||
const id = event.tags.find(nthEq(0, "E"))?.[1]
|
||||
|
||||
if (id && kind) {
|
||||
if (parseInt(kind) === THREAD) {
|
||||
return goto(makeThreadPath(url, id))
|
||||
}
|
||||
|
||||
if (parseInt(kind) === EVENT_TIME) {
|
||||
return goto(makeCalendarPath(url, id))
|
||||
}
|
||||
|
||||
if (parseInt(kind) === MESSAGE) {
|
||||
return goToMessage(url, room, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.open(entityLink(nip19.neventEncode({id: event.id, relays: urls})))
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import twColors from "tailwindcss/colors"
|
||||
import {get, derived} from "svelte/store"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {
|
||||
on,
|
||||
call,
|
||||
remove,
|
||||
uniqBy,
|
||||
sortBy,
|
||||
sort,
|
||||
uniq,
|
||||
@@ -15,10 +18,12 @@ import {
|
||||
memoize,
|
||||
addToMapKey,
|
||||
identity,
|
||||
groupBy,
|
||||
always,
|
||||
} from "@welshman/lib"
|
||||
import {load} from "@welshman/net"
|
||||
import {collection} from "@welshman/store"
|
||||
import type {Socket} from "@welshman/net"
|
||||
import {Pool, load, AuthStateEvent, SocketEvent} from "@welshman/net"
|
||||
import {collection, custom} from "@welshman/store"
|
||||
import {
|
||||
getIdFilters,
|
||||
WRAP,
|
||||
@@ -27,11 +32,15 @@ import {
|
||||
REACTION,
|
||||
ZAP_RESPONSE,
|
||||
DIRECT_MESSAGE,
|
||||
DIRECT_MESSAGE_FILE,
|
||||
GROUP_META,
|
||||
MESSAGE,
|
||||
GROUPS,
|
||||
THREAD,
|
||||
COMMENT,
|
||||
GROUP_JOIN,
|
||||
GROUP_ADD_USER,
|
||||
GROUP_REMOVE_USER,
|
||||
getGroupTags,
|
||||
getRelayTagValues,
|
||||
getPubkeyTagValues,
|
||||
@@ -41,6 +50,8 @@ import {
|
||||
getListTags,
|
||||
asDecryptedEvent,
|
||||
normalizeRelayUrl,
|
||||
getTag,
|
||||
getTagValues,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
|
||||
import {Nip59, decrypt} from "@welshman/signer"
|
||||
@@ -71,8 +82,6 @@ export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
|
||||
|
||||
export const ROOM = "h"
|
||||
|
||||
export const GENERAL = "_"
|
||||
|
||||
export const PROTECTED = ["-"]
|
||||
|
||||
export const ALERT = 32830
|
||||
@@ -97,7 +106,7 @@ export const PLATFORM_LOGO = PLATFORM_URL + "/pwa-192x192.png"
|
||||
|
||||
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
|
||||
|
||||
export const PLATFORM_RELAY = import.meta.env.VITE_PLATFORM_RELAY
|
||||
export const PLATFORM_RELAYS = fromCsv(import.meta.env.VITE_PLATFORM_RELAYS)
|
||||
|
||||
export const PLATFORM_ACCENT = import.meta.env.VITE_PLATFORM_ACCENT
|
||||
|
||||
@@ -396,7 +405,7 @@ export const memberships = deriveEventsMapped<PublishedList>(repository, {
|
||||
})
|
||||
|
||||
export const {
|
||||
indexStore: membershipByPubkey,
|
||||
indexStore: membershipsByPubkey,
|
||||
deriveItem: deriveMembership,
|
||||
loadItem: loadMembership,
|
||||
} = collection({
|
||||
@@ -408,7 +417,9 @@ export const {
|
||||
|
||||
// Chats
|
||||
|
||||
export const chatMessages = deriveEvents(repository, {filters: [{kinds: [DIRECT_MESSAGE]}]})
|
||||
export const chatMessages = deriveEvents(repository, {
|
||||
filters: [{kinds: [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE]}],
|
||||
})
|
||||
|
||||
export type Chat = {
|
||||
id: string
|
||||
@@ -476,126 +487,93 @@ export const chatSearch = derived(chats, $chats =>
|
||||
|
||||
// Messages
|
||||
|
||||
export const messages = derived(
|
||||
deriveEvents(repository, {filters: [{kinds: [MESSAGE]}]}),
|
||||
$events => $events,
|
||||
)
|
||||
|
||||
// Nip29
|
||||
|
||||
export const groupMeta = deriveEvents(repository, {filters: [{kinds: [GROUP_META]}]})
|
||||
|
||||
export const hasNip29 = (relay?: Relay) =>
|
||||
relay?.profile?.supported_nips?.map?.(String)?.includes?.("29")
|
||||
export const messages = deriveEvents(repository, {filters: [{kinds: [MESSAGE]}]})
|
||||
|
||||
// Channels
|
||||
|
||||
export type ChannelMeta = {
|
||||
access: "public" | "private"
|
||||
membership: "open" | "closed"
|
||||
export type Channel = {
|
||||
id: string
|
||||
url: string
|
||||
room: string
|
||||
name: string
|
||||
event: TrustedEvent
|
||||
closed: boolean
|
||||
private: boolean
|
||||
picture?: string
|
||||
about?: string
|
||||
}
|
||||
|
||||
export type Channel = {
|
||||
url: string
|
||||
room: string
|
||||
name: string
|
||||
meta?: ChannelMeta
|
||||
}
|
||||
export const makeChannelId = (url: string, room: string) => `${url}'${room}`
|
||||
|
||||
export const makeChannelId = (url: string, room: string) => `${url}|${room}`
|
||||
export const splitChannelId = (id: string) => id.split("'")
|
||||
|
||||
export const splitChannelId = (id: string) => id.split("|")
|
||||
export const hasNip29 = (relay?: Relay) =>
|
||||
relay?.profile?.supported_nips?.map?.(String)?.includes?.("29")
|
||||
|
||||
export const channelsById = withGetter(
|
||||
derived(
|
||||
[groupMeta, memberships, messages, getUrlsForEvent],
|
||||
([$groupMeta, $memberships, $messages, $getUrlsForEvent]) => {
|
||||
const channelsById = new Map<string, Channel>()
|
||||
export const channelEvents = deriveEvents(repository, {filters: [{kinds: [GROUP_META]}]})
|
||||
|
||||
// Add meta using group meta events
|
||||
for (const event of $groupMeta) {
|
||||
const meta = fromPairs(event.tags)
|
||||
const room = meta.d
|
||||
export const channels = derived(
|
||||
[channelEvents, getUrlsForEvent],
|
||||
([$channelEvents, $getUrlsForEvent]) => {
|
||||
const $channels: Channel[] = []
|
||||
|
||||
if (room) {
|
||||
for (const url of $getUrlsForEvent(event.id)) {
|
||||
const id = makeChannelId(url, room)
|
||||
for (const event of $channelEvents) {
|
||||
const meta = fromPairs(event.tags)
|
||||
const room = meta.d
|
||||
|
||||
channelsById.set(id, {
|
||||
url,
|
||||
room,
|
||||
name: meta.name || room,
|
||||
meta: {
|
||||
access: meta.private ? "private" : "public",
|
||||
membership: meta.closed ? "closed" : "open",
|
||||
picture: meta.picture,
|
||||
about: meta.about,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add known rooms based on membership events
|
||||
for (const membership of $memberships) {
|
||||
for (const {url, room, name} of getMembershipRooms(membership)) {
|
||||
if (room) {
|
||||
for (const url of $getUrlsForEvent(event.id)) {
|
||||
const id = makeChannelId(url, room)
|
||||
|
||||
if (!channelsById.has(id)) {
|
||||
channelsById.set(id, {url, room, name})
|
||||
}
|
||||
$channels.push({
|
||||
id,
|
||||
url,
|
||||
room,
|
||||
event,
|
||||
name: meta.name || room,
|
||||
closed: Boolean(getTag("closed", event.tags)),
|
||||
private: Boolean(getTag("private", event.tags)),
|
||||
picture: meta.picture,
|
||||
about: meta.about,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add rooms based on known messages
|
||||
for (const event of $messages) {
|
||||
const [_, room] = event.tags.find(nthEq(0, ROOM)) || []
|
||||
|
||||
if (room) {
|
||||
for (const url of $getUrlsForEvent(event.id)) {
|
||||
const id = makeChannelId(url, room)
|
||||
|
||||
if (!channelsById.has(id)) {
|
||||
channelsById.set(id, {url, room, name: room})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return channelsById
|
||||
},
|
||||
),
|
||||
return uniqBy(c => c.id, $channels)
|
||||
},
|
||||
)
|
||||
|
||||
export const deriveChannel = (url: string, room: string) =>
|
||||
derived(channelsById, $channelsById => $channelsById.get(makeChannelId(url, room)))
|
||||
export const channelsByUrl = derived(channels, $channels => groupBy(c => c.url, $channels))
|
||||
|
||||
export const channelsByUrl = derived(channelsById, $channelsById => {
|
||||
const $channelsByUrl = new Map<string, Channel[]>()
|
||||
export const {
|
||||
indexStore: channelsById,
|
||||
deriveItem: _deriveChannel,
|
||||
loadItem: _loadChannel,
|
||||
} = collection({
|
||||
name: "channels",
|
||||
store: channels,
|
||||
getKey: channel => channel.id,
|
||||
load: async (id: string) => {
|
||||
const [url, room] = splitChannelId(id)
|
||||
|
||||
for (const channel of $channelsById.values()) {
|
||||
pushToMapKey($channelsByUrl, channel.url, channel)
|
||||
}
|
||||
|
||||
return $channelsByUrl
|
||||
await load({
|
||||
relays: [url],
|
||||
filters: [{kinds: [GROUP_META], "#d": [room]}],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const displayChannel = (url: string, room: string) => {
|
||||
if (room === GENERAL) {
|
||||
return "general"
|
||||
}
|
||||
export const deriveChannel = (url: string, room: string) => _deriveChannel(makeChannelId(url, room))
|
||||
|
||||
return channelsById.get().get(makeChannelId(url, room))?.name || room
|
||||
}
|
||||
export const loadChannel = (url: string, room: string) => _loadChannel(makeChannelId(url, room))
|
||||
|
||||
export const displayChannel = (url: string, room: string) =>
|
||||
channelsById.get().get(makeChannelId(url, room))?.name || room
|
||||
|
||||
export const roomComparator = (url: string) => (room: string) =>
|
||||
displayChannel(url, room).toLowerCase()
|
||||
|
||||
export const channelIsLocked = (channel?: Channel) =>
|
||||
channel?.meta?.access === "private" && channel?.meta?.membership === "closed"
|
||||
|
||||
// User stuff
|
||||
|
||||
export const userSettings = withGetter(
|
||||
@@ -615,26 +593,28 @@ export const userSettingValues = withGetter(
|
||||
export const getSetting = <T>(key: keyof Settings["values"]) => userSettingValues.get()[key] as T
|
||||
|
||||
export const userMembership = withGetter(
|
||||
derived([pubkey, membershipByPubkey], ([$pubkey, $membershipByPubkey]) => {
|
||||
derived([pubkey, membershipsByPubkey], ([$pubkey, $membershipsByPubkey]) => {
|
||||
if (!$pubkey) return undefined
|
||||
|
||||
loadMembership($pubkey)
|
||||
|
||||
return $membershipByPubkey.get($pubkey)
|
||||
return $membershipsByPubkey.get($pubkey)
|
||||
}),
|
||||
)
|
||||
|
||||
export const userRoomsByUrl = withGetter(
|
||||
derived(userMembership, $userMembership => {
|
||||
derived([userMembership, channelsById], ([$userMembership, $channelsById]) => {
|
||||
const tags = getListTags($userMembership)
|
||||
const $userRoomsByUrl = new Map<string, Set<string>>()
|
||||
|
||||
for (const [_, room, url] of getGroupTags(tags)) {
|
||||
addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), room)
|
||||
for (const url of getRelayTagValues(tags)) {
|
||||
$userRoomsByUrl.set(normalizeRelayUrl(url), new Set())
|
||||
}
|
||||
|
||||
for (const url of getRelayTagValues(tags)) {
|
||||
addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), GENERAL)
|
||||
for (const [_, room, url] of getGroupTags(tags)) {
|
||||
if ($channelsById.has(makeChannelId(url, room))) {
|
||||
addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), room)
|
||||
}
|
||||
}
|
||||
|
||||
return $userRoomsByUrl
|
||||
@@ -643,7 +623,7 @@ export const userRoomsByUrl = withGetter(
|
||||
|
||||
export const deriveUserRooms = (url: string) =>
|
||||
derived(userRoomsByUrl, $userRoomsByUrl =>
|
||||
sortBy(roomComparator(url), uniq(Array.from($userRoomsByUrl.get(url) || [GENERAL]))),
|
||||
sortBy(roomComparator(url), uniq(Array.from($userRoomsByUrl.get(url) || []))),
|
||||
)
|
||||
|
||||
export const deriveOtherRooms = (url: string) =>
|
||||
@@ -654,6 +634,41 @@ export const deriveOtherRooms = (url: string) =>
|
||||
),
|
||||
)
|
||||
|
||||
export enum MembershipStatus {
|
||||
Initial,
|
||||
Pending,
|
||||
Granted,
|
||||
}
|
||||
|
||||
export const deriveUserMembershipStatus = (url: string, room: string) =>
|
||||
derived(
|
||||
[
|
||||
pubkey,
|
||||
deriveEventsForUrl(url, [
|
||||
{kinds: [GROUP_JOIN, GROUP_ADD_USER, GROUP_REMOVE_USER], "#h": [room]},
|
||||
]),
|
||||
],
|
||||
([$pubkey, $events]) => {
|
||||
let status = MembershipStatus.Initial
|
||||
|
||||
for (const event of $events) {
|
||||
if (event.kind === GROUP_JOIN && event.pubkey === $pubkey) {
|
||||
status = MembershipStatus.Pending
|
||||
}
|
||||
|
||||
if (event.kind === GROUP_REMOVE_USER && getTagValues("p", event.tags).includes($pubkey!)) {
|
||||
break
|
||||
}
|
||||
|
||||
if (event.kind === GROUP_ADD_USER && getTagValues("p", event.tags).includes($pubkey!)) {
|
||||
return MembershipStatus.Granted
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
},
|
||||
)
|
||||
|
||||
// Other utils
|
||||
|
||||
export const encodeRelay = (url: string) =>
|
||||
@@ -670,3 +685,19 @@ export const displayReaction = (content: string) => {
|
||||
if (content === "-") return "👎"
|
||||
return content
|
||||
}
|
||||
|
||||
export const deriveSocket = (url: string) =>
|
||||
custom<Socket>(set => {
|
||||
const pool = Pool.get()
|
||||
const socket = pool.get(url)
|
||||
|
||||
set(socket)
|
||||
|
||||
const subs = [
|
||||
on(socket, SocketEvent.Error, () => set(socket)),
|
||||
on(socket, SocketEvent.Status, () => set(socket)),
|
||||
on(socket.auth, AuthStateEvent.Status, () => set(socket)),
|
||||
]
|
||||
|
||||
return () => subs.forEach(call)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.755 2H7.24502C6.08614 2 5.50671 2 5.03939 2.16261C4.15322 2.47096 3.45748 3.18719 3.15795 4.09946C3 4.58055 3 5.17705 3 6.37006V20.3742C3 21.2324 3.985 21.6878 4.6081 21.1176C4.97417 20.7826 5.52583 20.7826 5.8919 21.1176L6.375 21.5597C7.01659 22.1468 7.98341 22.1468 8.625 21.5597C9.26659 20.9726 10.2334 20.9726 10.875 21.5597C11.5166 22.1468 12.4834 22.1468 13.125 21.5597C13.7666 20.9726 14.7334 20.9726 15.375 21.5597C16.0166 22.1468 16.9834 22.1468 17.625 21.5597L18.1081 21.1176C18.4742 20.7826 19.0258 20.7826 19.3919 21.1176C20.015 21.6878 21 21.2324 21 20.3742V6.37006C21 5.17705 21 4.58055 20.842 4.09946C20.5425 3.18719 19.8468 2.47096 18.9606 2.16261C18.4933 2 17.9139 2 16.755 2Z" stroke="#1C274C" stroke-width="1.5"/>
|
||||
<path d="M10.5 11L17 11" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M7 11H7.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M7 7.5H7.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M7 14.5H7.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M10.5 7.5H17" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M10.5 14.5H17" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 16.0909V11.0975C21 6.80891 21 4.6646 19.682 3.3323C18.364 2 16.2426 2 12 2C7.75736 2 5.63604 2 4.31802 3.3323C3 4.6646 3 6.80891 3 11.0975V16.0909C3 19.1875 3 20.7358 3.73411 21.4123C4.08421 21.735 4.52615 21.9377 4.99692 21.9915C5.98402 22.1045 7.13673 21.0849 9.44216 19.0458C10.4612 18.1445 10.9708 17.6938 11.5603 17.5751C11.8506 17.5166 12.1494 17.5166 12.4397 17.5751C13.0292 17.6938 13.5388 18.1445 14.5578 19.0458C16.8633 21.0849 18.016 22.1045 19.0031 21.9915C19.4739 21.9377 19.9158 21.735 20.2659 21.4123C21 20.7358 21 19.1875 21 16.0909Z" stroke="#1C274D" stroke-width="1.5"/>
|
||||
<path d="M15 6H9" stroke="#1C274D" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 784 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.96173 18.9111L9.42605 18.3221L8.96173 18.9111ZM12 5.50088L11.4596 6.02098C11.601 6.16787 11.7961 6.25088 12 6.25088C12.2039 6.25088 12.399 6.16787 12.5404 6.02098L12 5.50088ZM15.0383 18.9111L15.5026 19.5001L15.0383 18.9111ZM9.42605 18.3221C7.91039 17.1273 6.25307 15.9605 4.93829 14.48C3.64922 13.0285 2.75 11.3347 2.75 9.13734H1.25C1.25 11.8029 2.3605 13.8363 3.81672 15.476C5.24723 17.0868 7.07077 18.3755 8.49742 19.5001L9.42605 18.3221ZM2.75 9.13734C2.75 6.98647 3.96537 5.18277 5.62436 4.42444C7.23607 3.68772 9.40166 3.88282 11.4596 6.02098L12.5404 4.98078C10.0985 2.44377 7.26409 2.02563 5.00076 3.0602C2.78471 4.07317 1.25 6.42527 1.25 9.13734H2.75ZM8.49742 19.5001C9.00965 19.9039 9.55954 20.3345 10.1168 20.6602C10.6739 20.9857 11.3096 21.2502 12 21.2502V19.7502C11.6904 19.7502 11.3261 19.6295 10.8736 19.3651C10.4213 19.1008 9.95208 18.7368 9.42605 18.3221L8.49742 19.5001ZM15.5026 19.5001C16.9292 18.3755 18.7528 17.0868 20.1833 15.476C21.6395 13.8363 22.75 11.8029 22.75 9.13734H21.25C21.25 11.3347 20.3508 13.0285 19.0617 14.48C17.7469 15.9605 16.0896 17.1273 14.574 18.3221L15.5026 19.5001ZM22.75 9.13734C22.75 6.42527 21.2153 4.07317 18.9992 3.0602C16.7359 2.02563 13.9015 2.44377 11.4596 4.98078L12.5404 6.02098C14.5983 3.88282 16.7639 3.68772 18.3756 4.42444C20.0346 5.18277 21.25 6.98647 21.25 9.13734H22.75ZM14.574 18.3221C14.0479 18.7368 13.5787 19.1008 13.1264 19.3651C12.6739 19.6295 12.3096 19.7502 12 19.7502V21.2502C12.6904 21.2502 13.3261 20.9857 13.8832 20.6602C14.4405 20.3345 14.9903 19.9039 15.5026 19.5001L14.574 18.3221Z" fill="#1C274C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 10.4167C3 7.21907 3 5.62028 3.37752 5.08241C3.75503 4.54454 5.25832 4.02996 8.26491 3.00079L8.83772 2.80472C10.405 2.26824 11.1886 2 12 2C12.8114 2 13.595 2.26824 15.1623 2.80472L15.7351 3.00079C18.7417 4.02996 20.245 4.54454 20.6225 5.08241C21 5.62028 21 7.21907 21 10.4167C21 10.8996 21 11.4234 21 11.9914C21 17.6294 16.761 20.3655 14.1014 21.5273C13.38 21.8424 13.0193 22 12 22C10.9807 22 10.62 21.8424 9.89856 21.5273C7.23896 20.3655 3 17.6294 3 11.9914C3 11.4234 3 10.8996 3 10.4167Z" stroke="#1C274C" stroke-width="1.5"/>
|
||||
<circle cx="12" cy="9" r="2" stroke="#1C274C" stroke-width="1.5"/>
|
||||
<path d="M16 15C16 16.1046 16 17 12 17C8 17 8 16.1046 8 15C8 13.8954 9.79086 13 12 13C14.2091 13 16 13.8954 16 15Z" stroke="#1C274C" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 864 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.15316 5.40838C10.4198 3.13613 11.0531 2 12 2C12.9469 2 13.5802 3.13612 14.8468 5.40837L15.1745 5.99623C15.5345 6.64193 15.7144 6.96479 15.9951 7.17781C16.2757 7.39083 16.6251 7.4699 17.3241 7.62805L17.9605 7.77203C20.4201 8.32856 21.65 8.60682 21.9426 9.54773C22.2352 10.4886 21.3968 11.4691 19.7199 13.4299L19.2861 13.9372C18.8096 14.4944 18.5713 14.773 18.4641 15.1177C18.357 15.4624 18.393 15.8341 18.465 16.5776L18.5306 17.2544C18.7841 19.8706 18.9109 21.1787 18.1449 21.7602C17.3788 22.3417 16.2273 21.8115 13.9243 20.7512L13.3285 20.4768C12.6741 20.1755 12.3469 20.0248 12 20.0248C11.6531 20.0248 11.3259 20.1755 10.6715 20.4768L10.0757 20.7512C7.77268 21.8115 6.62118 22.3417 5.85515 21.7602C5.08912 21.1787 5.21588 19.8706 5.4694 17.2544L5.53498 16.5776C5.60703 15.8341 5.64305 15.4624 5.53586 15.1177C5.42868 14.773 5.19043 14.4944 4.71392 13.9372L4.2801 13.4299C2.60325 11.4691 1.76482 10.4886 2.05742 9.54773C2.35002 8.60682 3.57986 8.32856 6.03954 7.77203L6.67589 7.62805C7.37485 7.4699 7.72433 7.39083 8.00494 7.17781C8.28555 6.96479 8.46553 6.64194 8.82547 5.99623L9.15316 5.40838Z" stroke="#1C274C" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
@@ -13,7 +13,7 @@
|
||||
const image = new Image()
|
||||
|
||||
image.addEventListener("error", () => {
|
||||
element.querySelector(".hidden")?.classList.remove("hidden")
|
||||
element?.querySelector(".hidden")?.classList.remove("hidden")
|
||||
})
|
||||
|
||||
image.src = src
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
{@render props.input?.()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="scroll-container content-sizing overflow-auto pt-2">
|
||||
<div class="scroll-container content-sizing h-full overflow-auto pt-2">
|
||||
{@render props.content?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
import {switcher} from "@welshman/lib"
|
||||
import AddSquare from "@assets/icons/Add Square.svg?dataurl"
|
||||
import ArrowsALogout2 from "@assets/icons/Arrows ALogout 2.svg?dataurl"
|
||||
import Bookmark from "@assets/icons/Bookmark.svg?dataurl"
|
||||
import BillList from "@assets/icons/Bill List.svg?dataurl"
|
||||
import Code2 from "@assets/icons/Code 2.svg?dataurl"
|
||||
import Document from "@assets/icons/Document.svg?dataurl"
|
||||
import Earth from "@assets/icons/Earth.svg?dataurl"
|
||||
@@ -43,6 +45,7 @@
|
||||
import Hashtag from "@assets/icons/Hashtag.svg?dataurl"
|
||||
import HamburgerMenu from "@assets/icons/Hamburger Menu.svg?dataurl"
|
||||
import HandPills from "@assets/icons/Hand Pills.svg?dataurl"
|
||||
import Heart from "@assets/icons/Heart.svg?dataurl"
|
||||
import HomeSmile from "@assets/icons/Home Smile.svg?dataurl"
|
||||
import Inbox from "@assets/icons/Inbox.svg?dataurl"
|
||||
import InfoCircle from "@assets/icons/Info Circle.svg?dataurl"
|
||||
@@ -71,13 +74,16 @@
|
||||
import Server from "@assets/icons/Server.svg?dataurl"
|
||||
import Settings from "@assets/icons/Settings.svg?dataurl"
|
||||
import SettingsMinimalistic from "@assets/icons/Settings Minimalistic.svg?dataurl"
|
||||
import ShieldUser from "@assets/icons/Shield User.svg?dataurl"
|
||||
import Station from "@assets/icons/Station.svg?dataurl"
|
||||
import TagHorizontal from "@assets/icons/Tag Horizontal.svg?dataurl"
|
||||
import Ticket from "@assets/icons/Ticket.svg?dataurl"
|
||||
import ShareCircle from "@assets/icons/Share Circle.svg?dataurl"
|
||||
import ShopMinimalistic from "@assets/icons/Shop Minimalistic.svg?dataurl"
|
||||
import SmileCircle from "@assets/icons/Smile Circle.svg?dataurl"
|
||||
import SquareShareLine from "@assets/icons/Square Share Line.svg?dataurl"
|
||||
import SortVertical from "@assets/icons/Sort Vertical.svg?dataurl"
|
||||
import Star from "@assets/icons/Star.svg?dataurl"
|
||||
import TrashBin2 from "@assets/icons/Trash Bin 2.svg?dataurl"
|
||||
import UFO3 from "@assets/icons/UFO 3.svg?dataurl"
|
||||
import UserHeart from "@assets/icons/User Heart.svg?dataurl"
|
||||
@@ -102,6 +108,8 @@
|
||||
const data = switcher(icon, {
|
||||
"add-square": AddSquare,
|
||||
"arrows-a-logout-2": ArrowsALogout2,
|
||||
bookmark: Bookmark,
|
||||
"bill-list": BillList,
|
||||
"code-2": Code2,
|
||||
document: Document,
|
||||
earth: Earth,
|
||||
@@ -136,6 +144,7 @@
|
||||
hashtag: Hashtag,
|
||||
"hamburger-menu": HamburgerMenu,
|
||||
"hand-pills": HandPills,
|
||||
heart: Heart,
|
||||
"home-smile": HomeSmile,
|
||||
inbox: Inbox,
|
||||
"info-circle": InfoCircle,
|
||||
@@ -167,12 +176,15 @@
|
||||
server: Server,
|
||||
settings: Settings,
|
||||
"settings-minimalistic": SettingsMinimalistic,
|
||||
"shield-user": ShieldUser,
|
||||
station: Station,
|
||||
"tag-horizontal": TagHorizontal,
|
||||
ticket: Ticket,
|
||||
"trash-bin-2": TrashBin2,
|
||||
"ufo-3": UFO3,
|
||||
"square-share-line": SquareShareLine,
|
||||
"sort-vertical": SortVertical,
|
||||
star: Star,
|
||||
"user-heart": UserHeart,
|
||||
"user-circle": UserCircle,
|
||||
"user-rounded": UserRounded,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import {AuthStatus, SocketStatus} from "@welshman/net"
|
||||
import {deriveSocket} from "@app/state"
|
||||
import StatusIndicator from "@lib/components/StatusIndicator.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
const socket = deriveSocket(url)
|
||||
</script>
|
||||
|
||||
{#if $socket.status === SocketStatus.Open}
|
||||
{#if $socket.auth.status === AuthStatus.None}
|
||||
<StatusIndicator class="bg-green-500">Connected</StatusIndicator>
|
||||
{:else if $socket.auth.status === AuthStatus.Requested}
|
||||
<StatusIndicator class="bg-yellow-500">Authenticating</StatusIndicator>
|
||||
{:else if $socket.auth.status === AuthStatus.PendingSignature}
|
||||
<StatusIndicator class="bg-yellow-500">Authenticating</StatusIndicator>
|
||||
{:else if $socket.auth.status === AuthStatus.DeniedSignature}
|
||||
<StatusIndicator class="bg-red-500">Failed to Authenticate</StatusIndicator>
|
||||
{:else if $socket.auth.status === AuthStatus.PendingResponse}
|
||||
<StatusIndicator class="bg-yellow-500">Authenticating</StatusIndicator>
|
||||
{:else if $socket.auth.status === AuthStatus.Forbidden}
|
||||
<StatusIndicator class="bg-red-500">Access Denied</StatusIndicator>
|
||||
{:else if $socket.auth.status === AuthStatus.Ok}
|
||||
<StatusIndicator class="bg-green-500">Connected</StatusIndicator>
|
||||
{/if}
|
||||
{:else if $socket.status === SocketStatus.Opening}
|
||||
<StatusIndicator class="bg-yellow-500">Connecting</StatusIndicator>
|
||||
{:else if $socket.status === SocketStatus.Closing}
|
||||
<StatusIndicator class="bg-gray-500">Not Connected</StatusIndicator>
|
||||
{:else if $socket.status === SocketStatus.Closed}
|
||||
<StatusIndicator class="bg-gray-500">Not Connected</StatusIndicator>
|
||||
{:else if $socket.status === SocketStatus.Error}
|
||||
<StatusIndicator class="bg-red-500">Failed to Connect</StatusIndicator>
|
||||
{/if}
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
|
||||
type Props = {
|
||||
children: Snippet
|
||||
class: string
|
||||
}
|
||||
|
||||
const {children, ...props}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full {props.class}"></div>
|
||||
<span class="text-sm">{@render children()}</span>
|
||||
</div>
|
||||
@@ -3,7 +3,7 @@
|
||||
import {throttle, clamp} from "@welshman/lib"
|
||||
import {preventDefault, stopPropagation} from "@lib/html"
|
||||
|
||||
const {term, search, select, component: Component, allowCreate = false} = $props()
|
||||
const {term, search, select, component: Component, style = "", allowCreate = false} = $props()
|
||||
|
||||
let index = $state(0)
|
||||
let items: string[] = $state([])
|
||||
@@ -57,7 +57,7 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div transition:fly|local={{duration: 200}} class="tiptap-suggestions">
|
||||
<div transition:fly|local={{duration: 200}} class="tiptap-suggestions" {style}>
|
||||
<div class="tiptap-suggestions__content max-h-[40vh]">
|
||||
{#if $term && allowCreate && !items.includes($term)}
|
||||
<button
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
...restProps
|
||||
} = $props()
|
||||
|
||||
const reactiveProps = $derived(props)
|
||||
|
||||
let element: Element
|
||||
|
||||
onMount(() => {
|
||||
@@ -28,7 +26,7 @@
|
||||
...params,
|
||||
})
|
||||
|
||||
instance = mount(component, {target, props: reactiveProps})
|
||||
instance = mount(component, {target, props})
|
||||
|
||||
return () => {
|
||||
popover?.destroy()
|
||||
|
||||
@@ -100,7 +100,7 @@ export const isIntersecting = async (element: Element) =>
|
||||
observer.observe(element)
|
||||
})
|
||||
|
||||
export const scrollToEvent = async (id: string) => {
|
||||
export const scrollToEvent = async (id: string, attempts = 3): Promise<boolean> => {
|
||||
const element = document.querySelector(`[data-event="${id}"]`) as any
|
||||
|
||||
if (element) {
|
||||
@@ -114,6 +114,8 @@ export const scrollToEvent = async (id: string) => {
|
||||
setTimeout(() => {
|
||||
element.style = ""
|
||||
}, 800 + 400)
|
||||
|
||||
return true
|
||||
} else {
|
||||
const lastElement = last(Array.from(document.querySelectorAll("[data-event]")))
|
||||
|
||||
@@ -123,6 +125,10 @@ export const scrollToEvent = async (id: string) => {
|
||||
|
||||
await sleep(300)
|
||||
|
||||
scrollToEvent(id)
|
||||
if (attempts > 0) {
|
||||
return scrollToEvent(id, attempts - 1)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {hexToBytes, bytesToHex} from "@noble/hashes/utils"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {range, DAY} from "@welshman/lib"
|
||||
import {range, DAY, hexToBytes, bytesToHex} from "@welshman/lib"
|
||||
|
||||
export const nsecEncode = (secret: string) => nip19.nsecEncode(hexToBytes(secret))
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import "@src/app.css"
|
||||
import "@capacitor-community/safe-area"
|
||||
import {onMount} from "svelte"
|
||||
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, memoize, sleep, defer, ago, WEEK, TaskQueue} from "@welshman/lib"
|
||||
import type {TrustedEvent, StampedEvent} from "@welshman/util"
|
||||
import {
|
||||
@@ -16,6 +16,7 @@
|
||||
MESSAGE,
|
||||
INBOX_RELAYS,
|
||||
DIRECT_MESSAGE,
|
||||
DIRECT_MESSAGE_FILE,
|
||||
MUTES,
|
||||
FOLLOWS,
|
||||
PROFILE,
|
||||
@@ -78,8 +79,6 @@
|
||||
Object.assign(window, {
|
||||
get,
|
||||
nip19,
|
||||
bytesToHex,
|
||||
hexToBytes,
|
||||
...lib,
|
||||
...welshmanSigner,
|
||||
...util,
|
||||
@@ -176,7 +175,9 @@
|
||||
return 1
|
||||
}
|
||||
|
||||
if ([EVENT_TIME, THREAD, MESSAGE, DIRECT_MESSAGE].includes(e.kind)) {
|
||||
if (
|
||||
[EVENT_TIME, THREAD, MESSAGE, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE].includes(e.kind)
|
||||
) {
|
||||
return 0.9
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {addToMapKey, dec, gt} from "@welshman/lib"
|
||||
import {GROUPS} from "@welshman/util"
|
||||
import {Router} from "@welshman/router"
|
||||
import {load} from "@welshman/net"
|
||||
import type {Relay} from "@welshman/app"
|
||||
import {relays, createSearch, loadRelay, loadRelaySelections} from "@welshman/app"
|
||||
import {createScroller} from "@lib/html"
|
||||
@@ -15,7 +18,7 @@
|
||||
import SpaceCheck from "@app/components/SpaceCheck.svelte"
|
||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||
import {
|
||||
membershipByPubkey,
|
||||
membershipsByPubkey,
|
||||
getMembershipUrls,
|
||||
loadMembership,
|
||||
userRoomsByUrl,
|
||||
@@ -24,8 +27,12 @@
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
const discoverRelays = () =>
|
||||
Promise.all(
|
||||
getDefaultPubkeys().map(async pubkey => {
|
||||
Promise.all([
|
||||
load({
|
||||
filters: [{kinds: [GROUPS]}],
|
||||
relays: Router.get().Index().getUrls(),
|
||||
}),
|
||||
...getDefaultPubkeys().map(async pubkey => {
|
||||
await loadRelaySelections(pubkey)
|
||||
|
||||
const membership = await loadMembership(pubkey)
|
||||
@@ -33,13 +40,13 @@
|
||||
|
||||
await Promise.all(urls.map(url => loadRelay(url)))
|
||||
}),
|
||||
)
|
||||
])
|
||||
|
||||
const wotGraph = $derived.by(() => {
|
||||
const scores = new Map<string, Set<string>>()
|
||||
|
||||
for (const pubkey of getDefaultPubkeys()) {
|
||||
for (const url of getMembershipUrls($membershipByPubkey.get(pubkey))) {
|
||||
for (const url of getMembershipUrls($membershipsByPubkey.get(pubkey))) {
|
||||
addToMapKey(scores, url, pubkey)
|
||||
}
|
||||
}
|
||||
@@ -87,7 +94,7 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<Page>
|
||||
<Page class="cw-full">
|
||||
<div class="content column gap-4" bind:this={element}>
|
||||
<PageHeader>
|
||||
{#snippet title()}
|
||||
|
||||
@@ -8,65 +8,63 @@
|
||||
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
import {PLATFORM_NAME, PLATFORM_RELAY} from "@app/state"
|
||||
import {PLATFORM_NAME, PLATFORM_RELAYS} from "@app/state"
|
||||
|
||||
const addSpace = () => pushModal(SpaceAdd)
|
||||
|
||||
onMount(() => {
|
||||
if (PLATFORM_RELAY) {
|
||||
goto(makeSpacePath(PLATFORM_RELAY))
|
||||
if (PLATFORM_RELAYS.length > 0) {
|
||||
goto(makeSpacePath(PLATFORM_RELAYS[0]))
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if !PLATFORM_RELAY}
|
||||
<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>
|
||||
{#snippet icon()}
|
||||
<div><Icon icon="add-circle" size={7} /></div>
|
||||
{/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>
|
||||
{#snippet icon()}
|
||||
<div><Icon icon="compass" size={7} /></div>
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Browse the network</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Find communities on the nostr network.</div>
|
||||
{/snippet}
|
||||
</CardButton>
|
||||
</Link>
|
||||
<Link href="/chat">
|
||||
<CardButton>
|
||||
{#snippet icon()}
|
||||
<div><Icon icon="chat-round" size={7} /></div>
|
||||
{/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>
|
||||
</Link>
|
||||
</div>
|
||||
<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>
|
||||
{#snippet icon()}
|
||||
<div><Icon icon="add-circle" size={7} /></div>
|
||||
{/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>
|
||||
{#snippet icon()}
|
||||
<div><Icon icon="compass" size={7} /></div>
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div>Browse the network</div>
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<div>Find communities on the nostr network.</div>
|
||||
{/snippet}
|
||||
</CardButton>
|
||||
</Link>
|
||||
<Link href="/chat">
|
||||
<CardButton>
|
||||
{#snippet icon()}
|
||||
<div><Icon icon="chat-round" size={7} /></div>
|
||||
{/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>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</label>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="col-2" bind:this={element}>
|
||||
<div class="col-2 h-full" bind:this={element}>
|
||||
{#each pubkeys.slice(0, limit) as pubkey (pubkey)}
|
||||
<PeopleItem {pubkey} />
|
||||
{/each}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {hexToBytes} from "@noble/hashes/utils"
|
||||
import {hexToBytes} from "@welshman/lib"
|
||||
import {displayPubkey, displayProfile} from "@welshman/util"
|
||||
import {pubkey, session, displayNip05, deriveProfile} from "@welshman/app"
|
||||
import {slideAndFade} from "@lib/transition"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {page} from "$app/stores"
|
||||
interface Props {
|
||||
children?: import("svelte").Snippet
|
||||
|
||||
type Props = {
|
||||
children?: Snippet
|
||||
}
|
||||
|
||||
const {children}: Props = $props()
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {onMount} from "svelte"
|
||||
import {page} from "$app/stores"
|
||||
import {ago, WEEK} from "@welshman/lib"
|
||||
import {GROUP_META, EVENT_TIME, GROUPS, THREAD, COMMENT, MESSAGE} from "@welshman/util"
|
||||
import {request} from "@welshman/net"
|
||||
import {ago, MONTH} from "@welshman/lib"
|
||||
import {GROUP_META, EVENT_TIME, THREAD, COMMENT, MESSAGE} from "@welshman/util"
|
||||
import Page from "@lib/components/Page.svelte"
|
||||
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
|
||||
import MenuSpace from "@app/components/MenuSpace.svelte"
|
||||
import SpaceAuthError from "@app/components/SpaceAuthError.svelte"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {getUploadUrl} from "@app/editor"
|
||||
import {setChecked} from "@app/notifications"
|
||||
import {checkRelayConnection, checkRelayAuth, checkRelayAccess} from "@app/commands"
|
||||
import {decodeRelay, userRoomsByUrl} from "@app/state"
|
||||
import {pullConservatively} from "@app/requests"
|
||||
import {notifications} from "@app/notifications"
|
||||
interface Props {
|
||||
children?: import("svelte").Snippet
|
||||
|
||||
type Props = {
|
||||
children?: Snippet
|
||||
}
|
||||
|
||||
const {children}: Props = $props()
|
||||
@@ -52,11 +52,8 @@
|
||||
onMount(() => {
|
||||
checkConnection()
|
||||
|
||||
// Prime our cache so inputs show up quickly
|
||||
getUploadUrl(url)
|
||||
|
||||
const relays = [url]
|
||||
const since = ago(WEEK)
|
||||
const since = ago(MONTH)
|
||||
const controller = new AbortController()
|
||||
|
||||
// Load group meta, threads, calendar events, comments, and recent messages
|
||||
@@ -71,9 +68,6 @@
|
||||
],
|
||||
})
|
||||
|
||||
// Completely refresh our groups list and listen for new ones
|
||||
request({relays, filters: [{kinds: [GROUPS]}], signal: controller.signal})
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
|
||||
@@ -1,49 +1,29 @@
|
||||
<script lang="ts">
|
||||
import {page} from "$app/stores"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import {deriveRelay} from "@welshman/app"
|
||||
import {fade} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import PageBar from "@lib/components/PageBar.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
|
||||
import ProfileFeed from "@app/components/ProfileFeed.svelte"
|
||||
import ChannelName from "@app/components/ChannelName.svelte"
|
||||
import ProfileLatest from "@app/components/ProfileLatest.svelte"
|
||||
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import RoomCreate from "@app/components/RoomCreate.svelte"
|
||||
import RelayDescription from "@app/components/RelayDescription.svelte"
|
||||
import {
|
||||
decodeRelay,
|
||||
channelIsLocked,
|
||||
makeChannelId,
|
||||
channelsById,
|
||||
deriveUserRooms,
|
||||
deriveOtherRooms,
|
||||
userRoomsByUrl,
|
||||
} from "@app/state"
|
||||
import {makeChatPath, makeThreadPath, makeCalendarPath, makeRoomPath} from "@app/routes"
|
||||
import {notifications} from "@app/notifications"
|
||||
import SpaceQuickLinks from "@app/components/SpaceQuickLinks.svelte"
|
||||
import SpaceRecentActivity from "@app/components/SpaceRecentActivity.svelte"
|
||||
import SpaceRelayStatus from "@app/components/SpaceRelayStatus.svelte"
|
||||
import {decodeRelay, userRoomsByUrl} from "@app/state"
|
||||
import {makeChatPath} from "@app/routes"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
const url = decodeRelay($page.params.relay)
|
||||
const relay = deriveRelay(url)
|
||||
const userRooms = deriveUserRooms(url)
|
||||
const otherRooms = deriveOtherRooms(url)
|
||||
const threadsPath = makeThreadPath(url)
|
||||
const calendarPath = makeCalendarPath(url)
|
||||
|
||||
const joinSpace = () => pushModal(SpaceJoin, {url})
|
||||
|
||||
const addRoom = () => pushModal(RoomCreate, {url})
|
||||
|
||||
let relayAdminEvents: TrustedEvent[] = $state([])
|
||||
|
||||
const pubkey = $derived($relay?.profile?.pubkey)
|
||||
const owner = $derived($relay?.profile?.pubkey)
|
||||
</script>
|
||||
|
||||
<PageBar>
|
||||
@@ -62,8 +42,8 @@
|
||||
<Icon icon="login-2" />
|
||||
Join Space
|
||||
</Button>
|
||||
{:else if pubkey}
|
||||
<Link class="btn btn-primary btn-sm" href={makeChatPath([pubkey])}>
|
||||
{:else if owner}
|
||||
<Link class="btn btn-primary btn-sm" href={makeChatPath([owner])}>
|
||||
<Icon icon="letter" />
|
||||
Contact Owner
|
||||
</Link>
|
||||
@@ -73,125 +53,66 @@
|
||||
{/snippet}
|
||||
</PageBar>
|
||||
|
||||
<PageContent class="flex flex-col p-2 pt-4">
|
||||
<div class="card2 bg-alt col-4 text-left">
|
||||
<PageContent class="flex flex-col gap-2 p-2 pt-4">
|
||||
<div class="card2 bg-alt flex flex-col gap-4 text-left">
|
||||
<div class="relative flex gap-4">
|
||||
<div class="relative">
|
||||
<div class="avatar relative">
|
||||
<div
|
||||
class="center !flex h-12 w-12 min-w-12 rounded-full border-2 border-solid border-base-300 bg-base-300">
|
||||
class="center !flex h-16 w-16 min-w-16 rounded-full border-2 border-solid border-base-300 bg-base-300">
|
||||
{#if $relay?.profile?.icon}
|
||||
<img alt="" src={$relay.profile.icon} />
|
||||
{:else}
|
||||
<Icon icon="ghost" size={5} />
|
||||
<Icon icon="ghost" size={6} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h2 class="ellipsize whitespace-nowrap text-xl">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h1 class="ellipsize whitespace-nowrap text-2xl font-bold">
|
||||
<RelayName {url} />
|
||||
</h2>
|
||||
</h1>
|
||||
<p class="ellipsize text-sm opacity-75">{displayRelayUrl(url)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<RelayDescription {url} />
|
||||
{#if $relay?.profile}
|
||||
{@const {software, version, supported_nips, limitation} = $relay.profile}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#if limitation?.auth_required}
|
||||
<p class="badge badge-neutral">
|
||||
<span class="ellipsize">Authentication Required</span>
|
||||
</p>
|
||||
{#if $relay?.profile?.terms_of_service || $relay?.profile?.privacy_policy}
|
||||
<div class="flex gap-3">
|
||||
{#if $relay.profile.terms_of_service}
|
||||
<Link href={$relay.profile.terms_of_service} class="badge badge-neutral flex gap-2">
|
||||
<Icon icon="bill-list" size={4} />
|
||||
Terms of Service
|
||||
</Link>
|
||||
{/if}
|
||||
{#if limitation?.payment_required}
|
||||
<p class="badge badge-neutral"><span class="ellipsize">Payment Required</span></p>
|
||||
{/if}
|
||||
{#if limitation?.min_pow_difficulty}
|
||||
<p class="badge badge-neutral">
|
||||
<span class="ellipsize">Requires PoW {limitation?.min_pow_difficulty}</span>
|
||||
</p>
|
||||
{/if}
|
||||
{#if Array.isArray(supported_nips)}
|
||||
<p class="badge badge-neutral">
|
||||
<span class="ellipsize">NIPs: {supported_nips.join(", ")}</span>
|
||||
</p>
|
||||
{/if}
|
||||
{#if software}
|
||||
<p class="badge badge-neutral"><span class="ellipsize">Software: {software}</span></p>
|
||||
{/if}
|
||||
{#if version}
|
||||
<p class="badge badge-neutral"><span class="ellipsize">Version: {version}</span></p>
|
||||
{#if $relay.profile.privacy_policy}
|
||||
<Link href={$relay?.profile?.privacy_policy} class="badge badge-neutral flex gap-2">
|
||||
<Icon icon="shield-user" size={4} />
|
||||
Privacy Policy
|
||||
</Link>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Divider>Your Rooms</Divider>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<Link href={threadsPath} class="btn btn-primary">
|
||||
<div class="relative flex items-center gap-2">
|
||||
<Icon icon="notes-minimalistic" />
|
||||
Threads
|
||||
{#if $notifications.has(threadsPath)}
|
||||
<div
|
||||
class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-primary-content"
|
||||
transition:fade>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Link>
|
||||
<Link href={calendarPath} class="btn btn-secondary">
|
||||
<div class="relative flex items-center gap-2">
|
||||
<Icon icon="notes-minimalistic" />
|
||||
Calendar
|
||||
{#if $notifications.has(calendarPath)}
|
||||
<div
|
||||
class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-primary-content"
|
||||
transition:fade>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Link>
|
||||
{#each $userRooms as room (room)}
|
||||
{@const roomPath = makeRoomPath(url, room)}
|
||||
<Link href={roomPath} class="btn btn-neutral relative">
|
||||
<div class="flex min-w-0 items-center gap-2 overflow-hidden text-nowrap">
|
||||
{#if channelIsLocked($channelsById.get(makeChannelId(url, room)))}
|
||||
<Icon icon="lock" size={4} />
|
||||
{:else}
|
||||
<Icon icon="hashtag" />
|
||||
{/if}
|
||||
<ChannelName {url} {room} />
|
||||
</div>
|
||||
{#if $notifications.has(roomPath)}
|
||||
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary" transition:fade></div>
|
||||
{/if}
|
||||
</Link>
|
||||
{/each}
|
||||
</div>
|
||||
<Divider>Other Rooms</Divider>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each $otherRooms as room (room)}
|
||||
<Link href={makeRoomPath(url, room)} class="btn btn-neutral">
|
||||
<div class="relative flex min-w-0 items-center gap-2 overflow-hidden text-nowrap">
|
||||
{#if channelIsLocked($channelsById.get(makeChannelId(url, room)))}
|
||||
<Icon icon="lock" size={4} />
|
||||
{:else}
|
||||
<Icon icon="hashtag" />
|
||||
{/if}
|
||||
<ChannelName {url} {room} />
|
||||
</div>
|
||||
</Link>
|
||||
{/each}
|
||||
<Button onclick={addRoom} class="btn btn-neutral whitespace-nowrap">
|
||||
<Icon icon="add-circle" />
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
{#if pubkey}
|
||||
<Divider>Recent posts from the relay admin</Divider>
|
||||
<div class="hidden flex-col gap-2" class:!flex={relayAdminEvents.length > 0}>
|
||||
<ProfileFeed hideLoading {url} {pubkey} bind:events={relayAdminEvents} />
|
||||
<SpaceQuickLinks {url} />
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<SpaceRecentActivity {url} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
<SpaceRelayStatus {url} />
|
||||
{#if owner}
|
||||
<div class="card2 bg-alt">
|
||||
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
|
||||
<Icon icon="user-rounded" />
|
||||
Latest Updates
|
||||
</h3>
|
||||
<ProfileLatest {url} pubkey={owner}>
|
||||
{#snippet fallback()}
|
||||
<p class="text-sm opacity-60">No recent posts from the relay admin</p>
|
||||
{/snippet}
|
||||
</ProfileLatest>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</PageContent>
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {readable} from "svelte/store"
|
||||
import {onMount, onDestroy} from "svelte"
|
||||
import {page} from "$app/stores"
|
||||
import type {Readable} from "svelte/store"
|
||||
import {now, formatTimestampAsDate} from "@welshman/lib"
|
||||
import {request} from "@welshman/net"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {createEvent, MESSAGE, DELETE, REACTION} from "@welshman/util"
|
||||
import {pubkey, publishThunk, deriveRelay} from "@welshman/app"
|
||||
import {
|
||||
createEvent,
|
||||
MESSAGE,
|
||||
DELETE,
|
||||
REACTION,
|
||||
GROUP_ADD_USER,
|
||||
GROUP_REMOVE_USER,
|
||||
} from "@welshman/util"
|
||||
import {pubkey, publishThunk, getThunkError} from "@welshman/app"
|
||||
import {slide, fade, fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
@@ -20,56 +29,69 @@
|
||||
import ChannelCompose from "@app/components/ChannelCompose.svelte"
|
||||
import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte"
|
||||
import {
|
||||
userRoomsByUrl,
|
||||
userSettingValues,
|
||||
decodeRelay,
|
||||
GENERAL,
|
||||
tagRoom,
|
||||
userRoomsByUrl,
|
||||
displayChannel,
|
||||
getEventsForUrl,
|
||||
deriveUserMembershipStatus,
|
||||
deriveChannel,
|
||||
MembershipStatus,
|
||||
} from "@app/state"
|
||||
import {setChecked, checked} from "@app/notifications"
|
||||
import {
|
||||
nip29,
|
||||
joinRoom,
|
||||
leaveRoom,
|
||||
addRoomMembership,
|
||||
removeRoomMembership,
|
||||
prependParent,
|
||||
getThunkError,
|
||||
} from "@app/commands"
|
||||
import {PROTECTED, hasNip29} from "@app/state"
|
||||
import {PROTECTED} from "@app/state"
|
||||
import {makeFeed} from "@app/requests"
|
||||
import {popKey} from "@app/implicit"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
const {room = GENERAL} = $page.params
|
||||
const {room} = $page.params
|
||||
const mounted = now()
|
||||
const lastChecked = $checked[$page.url.pathname]
|
||||
const url = decodeRelay($page.params.relay)
|
||||
const channel = deriveChannel(url, room)
|
||||
const filter = {kinds: [MESSAGE], "#h": [room]}
|
||||
const relay = deriveRelay(url)
|
||||
const isFavorite = $derived($userRoomsByUrl.get(url)?.has(room))
|
||||
const membershipStatus = deriveUserMembershipStatus(url, room)
|
||||
|
||||
const joinRoom = async () => {
|
||||
if (hasNip29($relay)) {
|
||||
joiningRoom = true
|
||||
const addFavorite = () => addRoomMembership(url, room)
|
||||
|
||||
const message = await getThunkError(nip29.joinRoom(url, room))
|
||||
const removeFavorite = () => removeRoomMembership(url, room)
|
||||
|
||||
joiningRoom = false
|
||||
const join = async () => {
|
||||
joining = true
|
||||
|
||||
if (message && !message.includes("already")) {
|
||||
try {
|
||||
const message = await getThunkError(joinRoom(url, room))
|
||||
|
||||
if (message && !message.startsWith("duplicate:")) {
|
||||
return pushToast({theme: "error", message})
|
||||
} else {
|
||||
// Restart the feed now that we're a member
|
||||
start()
|
||||
}
|
||||
} finally {
|
||||
joining = false
|
||||
}
|
||||
|
||||
addRoomMembership(url, room, displayChannel(url, room))
|
||||
}
|
||||
|
||||
const leaveRoom = () => {
|
||||
if (hasNip29($relay)) {
|
||||
nip29.leaveRoom(url, room)
|
||||
}
|
||||
const leave = async () => {
|
||||
leaving = true
|
||||
try {
|
||||
const message = await getThunkError(leaveRoom(url, room))
|
||||
|
||||
removeRoomMembership(url, room)
|
||||
if (message && !message.startsWith("duplicate:")) {
|
||||
pushToast({theme: "error", message})
|
||||
}
|
||||
} finally {
|
||||
leaving = false
|
||||
}
|
||||
}
|
||||
|
||||
const replyTo = (event: TrustedEvent) => {
|
||||
@@ -130,7 +152,8 @@
|
||||
|
||||
const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"})
|
||||
|
||||
let joiningRoom = $state(false)
|
||||
let joining = $state(false)
|
||||
let leaving = $state(false)
|
||||
let loadingEvents = $state(true)
|
||||
let share = $state(popKey<TrustedEvent | undefined>("share"))
|
||||
let parent: TrustedEvent | undefined = $state()
|
||||
@@ -202,8 +225,10 @@
|
||||
return elements
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
;({events, cleanup} = makeFeed({
|
||||
const start = () => {
|
||||
cleanup?.()
|
||||
|
||||
const feed = makeFeed({
|
||||
element: element!,
|
||||
relays: [url],
|
||||
feedFilters: [filter],
|
||||
@@ -212,7 +237,27 @@
|
||||
onExhausted: () => {
|
||||
loadingEvents = false
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
events = feed.events
|
||||
cleanup = feed.cleanup
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
request({
|
||||
signal: controller.signal,
|
||||
relays: [url],
|
||||
filters: [
|
||||
{
|
||||
kinds: [GROUP_ADD_USER, GROUP_REMOVE_USER],
|
||||
"#p": [$pubkey!],
|
||||
"#h": [room],
|
||||
limit: 10,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (dynamicPadding && chatCompose) {
|
||||
@@ -222,8 +267,10 @@
|
||||
|
||||
observer.observe(chatCompose!)
|
||||
observer.observe(dynamicPadding!)
|
||||
start()
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
observer.unobserve(chatCompose!)
|
||||
observer.unobserve(dynamicPadding!)
|
||||
}
|
||||
@@ -252,23 +299,39 @@
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<div class="row-2">
|
||||
{#if room !== GENERAL}
|
||||
{#if $userRoomsByUrl.get(url)?.has(room)}
|
||||
<Button class="btn btn-neutral btn-sm" onclick={leaveRoom}>
|
||||
<Icon icon="arrows-a-logout-2" />
|
||||
Leave Room
|
||||
</Button>
|
||||
{:else}
|
||||
<Button class="btn btn-neutral btn-sm" disabled={joiningRoom} onclick={joinRoom}>
|
||||
{#if joiningRoom}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<Icon icon="login-2" />
|
||||
{/if}
|
||||
Join Room
|
||||
</Button>
|
||||
{/if}
|
||||
{#if $membershipStatus === MembershipStatus.Initial}
|
||||
<Button
|
||||
class="btn btn-neutral btn-sm tooltip tooltip-left"
|
||||
data-tip="Request to be added to the member list"
|
||||
disabled={joining}
|
||||
onclick={join}>
|
||||
{#if joining}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<Icon size={4} icon="login-2" />
|
||||
{/if}
|
||||
</Button>
|
||||
{:else if $membershipStatus === MembershipStatus.Pending}
|
||||
<Button
|
||||
class="btn btn-neutral btn-sm tooltip tooltip-left"
|
||||
data-tip="Membership is pending">
|
||||
<Icon size={4} icon="clock-circle" />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
class="btn btn-neutral btn-sm tooltip tooltip-left"
|
||||
data-tip="Request to be removed from member list"
|
||||
disabled={leaving}
|
||||
onclick={leave}>
|
||||
<Icon size={4} icon="arrows-a-logout-2" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
class="btn btn-neutral btn-sm tooltip tooltip-left"
|
||||
data-tip={isFavorite ? "Remove Favorite" : "Add Favorite"}
|
||||
onclick={isFavorite ? removeFavorite : addFavorite}>
|
||||
<Icon size={4} icon="bookmark" class={cx({"text-primary": isFavorite})} />
|
||||
</Button>
|
||||
<MenuSpaceButton {url} />
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -276,48 +339,93 @@
|
||||
|
||||
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
|
||||
<div bind:this={dynamicPadding}></div>
|
||||
{#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"></div>
|
||||
<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>
|
||||
{#if $channel?.private && $membershipStatus !== MembershipStatus.Granted}
|
||||
<div class="py-20">
|
||||
<div class="card2 col-8 m-auto max-w-md items-center text-center">
|
||||
<p class="row-2">You aren't currently a member of this room.</p>
|
||||
{#if $membershipStatus === MembershipStatus.Pending}
|
||||
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
|
||||
<Icon icon="clock-circle" />
|
||||
Access Pending
|
||||
</Button>
|
||||
{:else}
|
||||
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
|
||||
{#if joining}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<Icon icon="login-2" />
|
||||
{/if}
|
||||
Join Room
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if type === "date"}
|
||||
<Divider>{value}</Divider>
|
||||
{:else}
|
||||
<div in:slide class:-mt-1={!showPubkey}>
|
||||
<ChannelMessage
|
||||
{url}
|
||||
{room}
|
||||
{replyTo}
|
||||
event={$state.snapshot(value as TrustedEvent)}
|
||||
{showPubkey} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
<p class="flex h-10 items-center justify-center py-20">
|
||||
{#if loadingEvents}
|
||||
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
|
||||
{:else}
|
||||
<Spinner>End of message history</Spinner>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#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"></div>
|
||||
<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>
|
||||
</div>
|
||||
{:else if type === "date"}
|
||||
<Divider>{value}</Divider>
|
||||
{:else}
|
||||
<div in:slide class:-mt-1={!showPubkey}>
|
||||
<ChannelMessage
|
||||
{url}
|
||||
{replyTo}
|
||||
event={$state.snapshot(value as TrustedEvent)}
|
||||
{showPubkey} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
<p class="flex h-10 items-center justify-center py-20">
|
||||
{#if loadingEvents}
|
||||
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
|
||||
{:else}
|
||||
<Spinner>End of message history</Spinner>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</PageContent>
|
||||
|
||||
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
|
||||
<div>
|
||||
{#if parent}
|
||||
<ChannelComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||
{/if}
|
||||
{#if share}
|
||||
<ChannelComposeParent event={share} clear={clearShare} verb="Sharing" />
|
||||
{/if}
|
||||
</div>
|
||||
<ChannelCompose bind:this={compose} {onSubmit} {url} />
|
||||
{#if $channel?.private && $membershipStatus !== MembershipStatus.Granted}
|
||||
<!-- pass -->
|
||||
{:else if $channel?.closed && $membershipStatus !== MembershipStatus.Granted}
|
||||
<div class="bg-alt card m-4 flex flex-row items-center justify-between px-4 py-3">
|
||||
<p>Only members are allowed to post to this room.</p>
|
||||
{#if $membershipStatus === MembershipStatus.Pending}
|
||||
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
|
||||
<Icon icon="clock-circle" />
|
||||
Access Pending
|
||||
</Button>
|
||||
{:else}
|
||||
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
|
||||
{#if joining}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<Icon icon="login-2" />
|
||||
{/if}
|
||||
Ask to Join
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
{#if parent}
|
||||
<ChannelComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||
{/if}
|
||||
{#if share}
|
||||
<ChannelComposeParent event={share} clear={clearShare} verb="Sharing" />
|
||||
{/if}
|
||||
</div>
|
||||
<ChannelCompose bind:this={compose} {onSubmit} {url} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showScrollButton}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
|
||||
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {GENERAL, getEventsForUrl, decodeRelay} from "@app/state"
|
||||
import {getEventsForUrl, decodeRelay} from "@app/state"
|
||||
import {makeCalendarFeed} from "@app/requests"
|
||||
import {setChecked} from "@app/notifications"
|
||||
|
||||
@@ -92,10 +92,8 @@
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const feedFilters = [{kinds: [EVENT_TIME], "#h": [GENERAL]}]
|
||||
const subscriptionFilters = [
|
||||
{kinds: [DELETE, REACTION, EVENT_TIME], "#h": [GENERAL], since: now()},
|
||||
]
|
||||
const feedFilters = [{kinds: [EVENT_TIME]}]
|
||||
const subscriptionFilters = [{kinds: [DELETE, REACTION, EVENT_TIME], since: now()}]
|
||||
|
||||
;({events, cleanup} = makeCalendarFeed({
|
||||
element: element!,
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
<script lang="ts">
|
||||
import {readable} from "svelte/store"
|
||||
import {onMount, onDestroy} from "svelte"
|
||||
import {page} from "$app/stores"
|
||||
import type {Readable} from "svelte/store"
|
||||
import {now, formatTimestampAsDate} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {createEvent, MESSAGE, DELETE, REACTION} from "@welshman/util"
|
||||
import {pubkey, publishThunk} 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 PageContent from "@lib/components/PageContent.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import MenuSpaceButton from "@app/components/MenuSpaceButton.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, getEventsForUrl} from "@app/state"
|
||||
import {setChecked, checked} from "@app/notifications"
|
||||
import {prependParent} from "@app/commands"
|
||||
import {PROTECTED} from "@app/state"
|
||||
import {makeFeed} from "@app/requests"
|
||||
import {popKey} from "@app/implicit"
|
||||
|
||||
const mounted = now()
|
||||
const lastChecked = $checked[$page.url.pathname]
|
||||
const url = decodeRelay($page.params.relay)
|
||||
const filter = {kinds: [MESSAGE]}
|
||||
|
||||
const replyTo = (event: TrustedEvent) => {
|
||||
parent = event
|
||||
compose?.focus()
|
||||
}
|
||||
|
||||
const clearParent = () => {
|
||||
parent = undefined
|
||||
}
|
||||
|
||||
const clearShare = () => {
|
||||
share = undefined
|
||||
}
|
||||
|
||||
const onSubmit = ({content, tags}: EventContent) => {
|
||||
tags.push(PROTECTED)
|
||||
|
||||
let template = {content, tags}
|
||||
|
||||
if (share) {
|
||||
template = prependParent(share, template)
|
||||
}
|
||||
|
||||
if (parent) {
|
||||
template = prependParent(parent, template)
|
||||
}
|
||||
|
||||
publishThunk({
|
||||
relays: [url],
|
||||
event: createEvent(MESSAGE, template),
|
||||
delay: $userSettingValues.send_delay,
|
||||
})
|
||||
|
||||
clearParent()
|
||||
clearShare()
|
||||
}
|
||||
|
||||
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 loadingEvents = $state(true)
|
||||
let share = $state(popKey<TrustedEvent | undefined>("share"))
|
||||
let parent: TrustedEvent | undefined = $state()
|
||||
let element: HTMLElement | undefined = $state()
|
||||
let newMessages: HTMLElement | undefined = $state()
|
||||
let chatCompose: HTMLElement | undefined = $state()
|
||||
let dynamicPadding: HTMLElement | undefined = $state()
|
||||
let newMessagesSeen = false
|
||||
let showFixedNewMessages = $state(false)
|
||||
let showScrollButton = $state(false)
|
||||
let cleanup: () => void
|
||||
let events: Readable<TrustedEvent[]> = $state(readable([]))
|
||||
let compose: ChannelCompose | undefined = $state()
|
||||
|
||||
const elements = $derived.by(() => {
|
||||
const elements = []
|
||||
const seen = new Set()
|
||||
|
||||
let previousDate
|
||||
let previousPubkey
|
||||
let newMessagesSeen = false
|
||||
|
||||
if (events) {
|
||||
const lastUserEvent = $events.find(e => e.pubkey === $pubkey)
|
||||
|
||||
// Adjust last checked to account for messages that came from a different device
|
||||
const adjustedLastChecked =
|
||||
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
|
||||
|
||||
for (const event of $events.toReversed()) {
|
||||
if (seen.has(event.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const date = formatTimestampAsDate(event.created_at)
|
||||
|
||||
if (
|
||||
!newMessagesSeen &&
|
||||
adjustedLastChecked &&
|
||||
event.pubkey !== $pubkey &&
|
||||
event.created_at > adjustedLastChecked &&
|
||||
event.created_at < mounted
|
||||
) {
|
||||
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: event.id,
|
||||
type: "note",
|
||||
value: event,
|
||||
showPubkey: date !== previousDate || previousPubkey !== event.pubkey,
|
||||
})
|
||||
|
||||
previousDate = date
|
||||
previousPubkey = event.pubkey
|
||||
seen.add(event.id)
|
||||
}
|
||||
}
|
||||
|
||||
elements.reverse()
|
||||
|
||||
setTimeout(onScroll, 100)
|
||||
|
||||
return elements
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (dynamicPadding && chatCompose) {
|
||||
dynamicPadding!.style.minHeight = `${chatCompose!.offsetHeight}px`
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(chatCompose!)
|
||||
observer.observe(dynamicPadding!)
|
||||
|
||||
const feed = makeFeed({
|
||||
element: element!,
|
||||
relays: [url],
|
||||
feedFilters: [filter],
|
||||
subscriptionFilters: [{kinds: [DELETE, REACTION, MESSAGE], since: now()}],
|
||||
initialEvents: getEventsForUrl(url, [{...filter, limit: 20}]),
|
||||
onExhausted: () => {
|
||||
loadingEvents = false
|
||||
},
|
||||
})
|
||||
|
||||
events = feed.events
|
||||
cleanup = feed.cleanup
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
observer.unobserve(chatCompose!)
|
||||
observer.unobserve(dynamicPadding!)
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
cleanup()
|
||||
|
||||
// Sveltekit calls onDestroy at the beginning of the page load for some reason
|
||||
setTimeout(() => {
|
||||
setChecked($page.url.pathname)
|
||||
}, 800)
|
||||
})
|
||||
</script>
|
||||
|
||||
<PageBar>
|
||||
{#snippet icon()}
|
||||
<div class="center">
|
||||
<Icon icon="chat-round" />
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<strong>Chat</strong>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<MenuSpaceButton {url} />
|
||||
{/snippet}
|
||||
</PageBar>
|
||||
|
||||
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
|
||||
<div bind:this={dynamicPadding}></div>
|
||||
{#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"></div>
|
||||
<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>
|
||||
</div>
|
||||
{:else if type === "date"}
|
||||
<Divider>{value}</Divider>
|
||||
{:else}
|
||||
<div in:slide class:-mt-1={!showPubkey}>
|
||||
<ChannelMessage
|
||||
{url}
|
||||
{replyTo}
|
||||
event={$state.snapshot(value as TrustedEvent)}
|
||||
{showPubkey} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
<p class="flex h-10 items-center justify-center py-20">
|
||||
{#if loadingEvents}
|
||||
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
|
||||
{:else}
|
||||
<Spinner>End of message history</Spinner>
|
||||
{/if}
|
||||
</p>
|
||||
</PageContent>
|
||||
|
||||
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
|
||||
<div>
|
||||
{#if parent}
|
||||
<ChannelComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||
{/if}
|
||||
{#if share}
|
||||
<ChannelComposeParent event={share} clear={clearShare} verb="Sharing" />
|
||||
{/if}
|
||||
</div>
|
||||
<ChannelCompose bind:this={compose} {onSubmit} {url} />
|
||||
</div>
|
||||
|
||||
{#if showScrollButton}
|
||||
<div in:fade class="chat__scroll-down">
|
||||
<Button class="btn btn-circle btn-neutral" onclick={scrollToBottom}>
|
||||
<Icon icon="alt-arrow-down" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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" onclick={scrollToNewMessages}>
|
||||
New Messages
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
After Width: | Height: | Size: 89 KiB |
@@ -29,16 +29,16 @@ export default {
|
||||
dark: {
|
||||
...themes["dark"],
|
||||
primary: process.env.VITE_PLATFORM_ACCENT,
|
||||
"primary-content": "#EAE7FF",
|
||||
"primary-content": process.env.VITE_PLATFORM_ACCENT_CONTENT || "#EAE7FF",
|
||||
secondary: process.env.VITE_PLATFORM_SECONDARY,
|
||||
"secondary-content": "#EAE7FF",
|
||||
"secondary-content": process.env.VITE_PLATFORM_SECONDARY_CONTENT || "#EAE7FF",
|
||||
},
|
||||
light: {
|
||||
...themes["winter"],
|
||||
primary: process.env.VITE_PLATFORM_ACCENT,
|
||||
"primary-content": "#EAE7FF",
|
||||
"primary-content": process.env.VITE_PLATFORM_ACCENT_CONTENT || "#EAE7FF",
|
||||
secondary: process.env.VITE_PLATFORM_SECONDARY,
|
||||
"secondary-content": "#EAE7FF",
|
||||
"secondary-content": process.env.VITE_PLATFORM_SECONDARY_CONTENT || "#EAE7FF",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||