Compare commits

..

45 Commits

Author SHA1 Message Date
Jon Staab e3e13563d5 bump pnpm version 2026-06-02 15:27:15 -07:00
userAdityaa ee3da3893c fix: resync voice state after LiveKit reconnect (#289)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-06-02 16:00:11 +00:00
Jon Staab 91145c38fb Scaffold playwright 2026-06-01 17:00:47 -07:00
Jon Staab 1dd0270f4f Bump pomade 2026-05-29 16:31:29 -07:00
userAdityaa 77256462c5 feat: sync checked read state to Dufflepud for cross-device badges (#288)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-29 21:15:56 +00:00
Jon Staab ae071fefaa Fail more gracefully when svelte sneezes 2026-05-29 08:30:42 -07:00
userAdityaa 152d35f92a Fix deleted rooms persisting in navigation (#285)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-29 15:20:21 +00:00
userAdityaa 8dd278f47c fix: turn on notification defaults and prompt on first DM visit (#284)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-29 15:13:10 +00:00
Jon Staab 045d6983dc Fix some voice room bugs 2026-05-28 12:17:17 -07:00
Jon Staab 2f8861be62 Bump welshman, update pnpm config 2026-05-28 12:14:40 -07:00
userAdityaa 6dbe9c0ebb chore: show space relay icon in mobile topbar headers (#283)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-25 19:18:51 +00:00
Jon Staab 45df132dc6 Remove tauri, bump deps 2026-05-25 10:41:34 -07:00
Jon Staab c42a285f0b Tweak wording 2026-05-25 09:04:14 -07:00
userAdityaa 1e3211ae74 chore: uppdate signup modal copy to focus on joining Flotilla (#282)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-25 16:00:28 +00:00
npub15skvhry ec507b05d6 minimize container size and caching 2026-05-22 15:36:28 -07:00
Jon Staab 339bb1afac Tweak page bar style 2026-05-21 15:17:28 -07:00
userAdityaa c441012e02 fix(video): restyle spotlight pin button on video tiles (#281)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-21 20:59:25 +00:00
userAdityaa 0d61278c56 chore: show call participant mute and camera-off state (#279)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-21 20:58:53 +00:00
userAdityaa ffd06ab561 fix: video blink when toggling mic mute in calls (#277)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-20 16:44:38 +00:00
userAdityaa eb8dd330b6 fix(video): use single-column tile grid when chat is open (#278)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-20 16:42:16 +00:00
userAdityaa 6267e52bdf feat: show unread chat badges during video-only voice calls (#276)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-19 15:36:22 +00:00
Jon Staab ab21008f34 Add etag for immutable assets 2026-05-12 08:46:50 -07:00
Jon Staab 0998639d59 Push to gitea package registry 2026-05-11 13:49:37 -07:00
Jon Staab eccde07d06 Fix dockerfile again 2026-05-11 13:20:47 -07:00
Jon Staab 770cdc5f13 Reduce extra space on android when keyboard is open 2026-05-11 12:44:44 -07:00
Jon Staab 6bafb62414 Fix docker build 2026-05-11 12:28:42 -07:00
Jon Staab 6ce0fbbbe6 Make recommended ios changes 2026-05-11 10:02:14 -07:00
Jon Staab 8fe42e6f22 Update version 2026-05-11 09:54:22 -07:00
Jon Staab 47a6209730 Bump welshman 2026-05-11 09:20:17 -07:00
Jon Staab 24d3f867f8 Improve space search 2026-05-07 12:53:49 -07:00
Jon Staab 9db60374e4 Make sure to always show date on calendar events when embedded in chat 2026-05-06 17:24:29 -07:00
Jon Staab 8ef4b21dab Allow sharing something to chat without a message 2026-05-06 17:17:55 -07:00
Jon Staab 8f56812dd1 Fix undefined chat draft key 2026-05-06 16:56:31 -07:00
Jon Staab 3833cb093d Attempt to fix wrapping on relay summary on ios 2026-05-06 16:39:01 -07:00
Jon Staab 94db65b85e Bump welshman, add email rendering support 2026-05-06 13:47:30 -07:00
Jon Staab 6f731e48d2 Hide keyboard on app resume 2026-05-06 12:48:15 -07:00
Jon Staab 99fe0e543c Avoid capturing stale cleanup function in chat 2026-05-06 09:31:28 -07:00
Jon Staab c6b0799b2a Remove cv class from chat since new messages weren't rendering in Safari 2026-05-06 09:25:29 -07:00
Jon Staab 861f2286db Remove unnecessary tooltip, fix chat padding on mobile 2026-05-06 09:01:01 -07:00
Jon Staab 9af3e3b2e9 Fix relay badge overflow 2026-05-05 09:11:12 -07:00
Jon Staab 341c1b45b2 Stop publishing join requests every time we open a space 2026-05-05 09:09:28 -07:00
Jon Staab 89f5d8cdf5 Fix pasting into event summary 2026-05-04 16:15:21 -07:00
Jon Staab ca3270437d Highlight active space 2026-05-04 16:11:30 -07:00
Khushvendra bbbc6f7363 fix(metadata): add case-insensitive HTML title fallback parsing for invite links (#248)
Co-authored-by: 1amKhush <khushvendras99@gmail.com>
Co-committed-by: 1amKhush <khushvendras99@gmail.com>
2026-05-04 21:02:56 +00:00
Jon Staab 8a0abacf6f Fix padding on page content on small screens 2026-05-01 13:24:42 -07:00
132 changed files with 4113 additions and 7992 deletions
-1
View File
@@ -4,7 +4,6 @@ ios
build build
# Git # Git
.git
.gitignore .gitignore
# Env files (keep .env for build; exclude local overrides) # Env files (keep .env for build; exclude local overrides)
+1
View File
@@ -15,3 +15,4 @@ android/capacitor-cordova-android-plugins
android/app/src/androidTest android/app/src/androidTest
android/app/src/test android/app/src/test
node_modules node_modules
.svelte-kit
@@ -1,12 +1,17 @@
name: Docker name: Container Image Build and Publish
on: on:
push: push:
branches: [master] branches: [master]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env: env:
REGISTRY: ghcr.io REGISTRY: gitea.coracle.social
IMAGE_NAME: coracle-social/flotilla IMAGE_NAME: coracle/flotilla
jobs: jobs:
build-and-push-image: build-and-push-image:
@@ -23,8 +28,8 @@ jobs:
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }} username: hodlbod
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.PACKAGE_TOKEN }}
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
@@ -32,6 +37,7 @@ jobs:
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=sha
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
@@ -45,6 +51,7 @@ jobs:
with: with:
context: . context: .
push: true push: true
target: production
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
+6 -4
View File
@@ -6,6 +6,11 @@
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# Playwright
/test-results/
/playwright-report/
/playwright/.cache/
# Generated assets # Generated assets
static/favicon.ico static/favicon.ico
static/pwa-64x64.png static/pwa-64x64.png
@@ -27,13 +32,10 @@ android/app/src/main/assets/public/
node_modules/ node_modules/
.pnpm-store/ .pnpm-store/
build/ build/
build-server/
.svelte-kit/ .svelte-kit/
.next/ .next/
# Rust/Tauri
*target/
src-tauri/binaries/
# iOS # iOS
ios/App/App/public ios/App/App/public
ios/DerivedData ios/DerivedData
+26
View File
@@ -1,5 +1,31 @@
# Changelog # Changelog
# 1.8.0
* Fix relay badge overflow
* Suppress programmatic scroll when user is scrolling
* Fix vertical alignment of emoji and overflow buttons in shared event action row
* Use type=email for signup/login email inputs, validate password
* Improve toggle switch placement on settings screens
* Fix relay auth privacy toggle
* Improve field layout
* Add progress bar to signup flow
* Bundle emojis properly
* Rework hosting page
* Fix padding on pages on small screens
* Add richer link preview support
* Fix pasting into event summary
* Publish fewer join/claim requests
* Fix new messages not rendering in safari
* Avoid capturing stale cleanup function in chat
* Hide keyboard on app resume
* Add email rendering support
* Fix bunker login
* Fix undefined chat draft key
* Allow sharing to chat without a message
* Make sure to show date on calendar events when embedded
* Improve space search
# 1.7.4 # 1.7.4
* Fix safe area inset for FAB * Fix safe area inset for FAB
+24 -25
View File
@@ -1,32 +1,31 @@
# Stage 1: Build # Build and run the Flotilla web server.
# Uses .env from build context for config (logo, branding, etc.) #
# Optional: docker build --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) -t flotilla . # docker build -t flotilla .
# docker run -p 3000:3000 flotilla
#
# Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build.
# A .env in the build context is picked up by build.sh for branding config.
FROM node:20-bookworm AS builder # https://pnpm.io/docker#example-3-build-on-cicd
FROM node:24-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends curl ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@latest RUN corepack enable
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm i
# Copy everything (including .env when present) - build.sh will source it
COPY . .
ARG VITE_BUILD_HASH
ENV VITE_BUILD_HASH=${VITE_BUILD_HASH}
ENV NODE_OPTIONS=--max_old_space_size=16384 ENV NODE_OPTIONS=--max_old_space_size=16384
COPY package.json pnpm-lock.yaml ./
RUN pnpm i --frozen-lockfile
COPY . .
ARG VITE_BUILD_HASH
RUN pnpm run build RUN pnpm run build
RUN pnpm run build:server
FROM node:20-alpine FROM node:24-slim AS production
ENV NODE_ENV=production
WORKDIR /app WORKDIR /app
COPY --from=builder /app/build /app/build
# Copy only the built output - no source, no .env, no dev deps COPY --from=builder /app/build-server/server.js /app/server.js
COPY --from=builder /app/build ./build EXPOSE 3000
USER node
CMD ["npx", "serve", "-s", "build"] CMD ["node", "server.js"]
+3 -3
View File
@@ -31,18 +31,18 @@ To run your own Flotilla, it's as simple as:
```sh ```sh
pnpm install pnpm install
pnpm run build pnpm run build
npx serve -s build pnpm run start
``` ```
Or, if you prefer to use a container: Or, if you prefer to use a container:
```sh ```sh
podman run -d -p 3000:3000 ghcr.io/coracle-social/flotilla:latest docker run -d -p 3000:3000 gitea.coracle.social/coracle/flotilla:latest
``` ```
Alternatively, you can copy the build files into a directory of your choice and serve it yourself: Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
```sh ```sh
mkdir ./mount mkdir ./mount
podman run -v ./mount:/app/mount ghcr.io/coracle-social/flotilla:latest bash -c 'cp -r build/* mount' docker run -v ./mount:/app/mount gitea.coracle.social/coracle/flotilla:latest bash -c 'cp -r build/* mount'
``` ```
+2 -2
View File
@@ -8,8 +8,8 @@ android {
applicationId "social.flotilla" applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion targetSdk rootProject.ext.targetSdkVersion
versionCode 46 versionCode 47
versionName "1.7.4" versionName "1.8.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1 -1
View File
@@ -36,4 +36,4 @@ include ':capawesome-capacitor-badge'
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge/android') project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge/android')
include ':nostr-signer-capacitor-plugin' include ':nostr-signer-capacitor-plugin'
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin/android') project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin/android')
+12
View File
@@ -0,0 +1,12 @@
import {expect, test} from "@playwright/test"
test("boots the SPA on the home page", async ({page}) => {
const response = await page.goto("/")
expect(response?.ok()).toBeTruthy()
// adapter-static serves an empty shell that hydrates client-side, so the presence of
// rendered text proves the Svelte app actually mounted (not just that a file was served).
// TODO: tighten this to assert concrete onboarding UI once the markup is settled.
await expect(page.locator("body")).toContainText(/\S/, {timeout: 15_000})
})
+29
View File
@@ -0,0 +1,29 @@
import type {SignedEvent} from "@welshman/util"
import type {RelayMockConfig} from "../../src/lib/test/relayMocks"
import relay1Events from "./fixtures/relay1.json"
// Fake relay urls used by tests. Each maps to a json fixture under ./fixtures/ and an entry in
// EVENTS_BY_RELAY below. To add a relay: drop a `<name>.json` file in ./fixtures/, import it, add a
// url here, and wire it into EVENTS_BY_RELAY.
export const FIXTURE_RELAYS = {
relay1: "wss://relay1.test/",
} as const
// The events each fake relay serves. The json files hold static, pre-signed events: schnorr
// signatures are non-deterministic, so events are signed once and committed verbatim (they pass
// verifyEvent, which netContext.isEventValid enforces). Regenerate with @welshman/signer:
// await Nip01Signer.fromSecret(secret).sign(makeEvent(kind, {content, created_at}))
const EVENTS_BY_RELAY: Record<string, SignedEvent[]> = {
[FIXTURE_RELAYS.relay1]: relay1Events as SignedEvent[],
}
// Build a RelayMockConfig populating the given fixture relays (all of them when none are passed).
// Any relay not included returns nothing, keeping tests offline.
export const relayFixtures = (...urls: string[]): RelayMockConfig => {
const selected = urls.length > 0 ? urls : Object.keys(EVENTS_BY_RELAY)
return {
relays: Object.fromEntries(selected.map(url => [url, EVENTS_BY_RELAY[url] ?? []])),
}
}
+29
View File
@@ -0,0 +1,29 @@
[
{
"kind": 0,
"content": "{\"name\":\"Alice\"}",
"tags": [],
"created_at": 1700000000,
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"id": "9b3d138641b38364945b20d800268006c2cb7d974bb4b1d63a9f90f5ab974b90",
"sig": "de6b86274e7bcf6c02aa881ada1feee9e01ba320691711d4975916b3cd231ab43cf469a47c5db99503ed72707d5db85fede1ad3763c4fbd7c998d04f00eda6bc"
},
{
"kind": 1,
"content": "hello from the fixture relay",
"tags": [],
"created_at": 1700000000,
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"id": "b9874875bfa8d830c5c9ef3673104360cf21b94848a311febfaf52f0a652b1a9",
"sig": "85df94a2e9884ac3d280145492d5191cde2948d49a824c443a1f5d2143633eff1e1789fa7e8843b6efc3dd2dc0d7e33322edb628125d8e35de8ddca1d06ca970"
},
{
"kind": 1,
"content": "reply from bob",
"tags": [],
"created_at": 1700000001,
"pubkey": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
"id": "171dcbdd63d474ba46da609e8b0104cbcf4801fbb581b6c343d9426280f9e1be",
"sig": "eecf26e616a6b70dc67c7eae16fc4fe159314647ba1d4581332257de7f070410aa9a9e41f571b9403ff145d16c9b32766846fce08516201263e25cf08c1ed8f1"
}
]
+30
View File
@@ -0,0 +1,30 @@
import type {Page} from "@playwright/test"
import type {RelayMockConfig} from "../../src/lib/test/relayMocks"
// Must match RELAY_MOCKS_KEY in src/lib/test/relayMocks.ts.
const RELAY_MOCKS_KEY = "__RELAY_MOCKS__"
// Hard safety net: intercept every real websocket so a test can never reach the network, even if
// some code path opens a socket directly (e.g. relay AUTH) rather than going through the adapter
// layer. We never call route.connectToServer(), so the socket connects to Playwright's in-process
// mock and simply receives nothing.
export const blockWebsockets = (page: Page) => page.routeWebSocket(/^wss?:\/\//, () => {})
// Inject the relay-mock config the app reads on startup. addInitScript runs before any page script
// on every navigation, so this must be called before page.goto().
export const injectRelayConfig = (page: Page, config: RelayMockConfig) =>
page.addInitScript(
([key, value]) => {
Object.assign(window, {[key]: value})
},
[RELAY_MOCKS_KEY, config] as const,
)
// Full network isolation plus optional fixtures, in one call. With no config, every relay returns
// nothing (requirement 1). Pass {relays: {url: events}} to populate specific relays (requirement 2).
export const setupRelayMocks = async (page: Page, config: RelayMockConfig = {}) => {
await blockWebsockets(page)
await injectRelayConfig(page, config)
}
export type {RelayMockConfig}
+29 -11
View File
@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 48; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -131,8 +131,9 @@
504EC2FC1FED79650016851F /* Project object */ = { 504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 920; LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 920; LastUpgradeCheck = 2630;
TargetAttributes = { TargetAttributes = {
504EC3031FED79650016851F = { 504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2; CreatedOnToolsVersion = 9.2;
@@ -257,6 +258,7 @@
CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES; CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_EMPTY_BODY = YES;
@@ -264,8 +266,10 @@
CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -275,8 +279,10 @@
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = S26U9DYW3A;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO; GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
@@ -295,6 +301,7 @@
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
@@ -314,6 +321,7 @@
CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES; CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_EMPTY_BODY = YES;
@@ -321,8 +329,10 @@
CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -332,8 +342,10 @@
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = S26U9DYW3A;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -345,7 +357,9 @@
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };
name = Release; name = Release;
@@ -358,14 +372,16 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 37; CURRENT_PROJECT_VERSION = 38;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
MARKETING_VERSION = 1.7.4; "$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.8.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -385,14 +401,16 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 37; CURRENT_PROJECT_VERSION = 38;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
MARKETING_VERSION = 1.7.4; "$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
+1 -1
View File
@@ -21,7 +21,7 @@ def capacitor_pods
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications' pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications'
pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share' pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share'
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge' pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin' pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin'
end end
target 'Flotilla Chat' do target 'Flotilla Chat' do
+30 -38
View File
@@ -1,18 +1,18 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "1.7.4", "version": "1.8.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "./build.sh", "build": "./build.sh",
"build:server": "vite build --config vite.config.server.ts",
"start": "node server.js",
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner", "release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:info": "tauri info",
"tauri:icons": "tauri icon assets/logo.png --output src-tauri/icons",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check src && eslint src", "lint": "prettier --check src && eslint src",
"test": "playwright test",
"test:ui": "playwright test --ui",
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write", "format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
"format:all": "prettier --write src", "format:all": "prettier --write src",
"prepare": "husky" "prepare": "husky"
@@ -20,26 +20,27 @@
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@sveltejs/kit": "^2.50.1", "@playwright/test": "^1.49.1",
"@sveltejs/vite-plugin-svelte": "^4.0.4", "@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@tauri-apps/cli": "^2.9.6",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/node": "^25.9.1",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^9.1.2", "eslint-config-prettier": "^9.1.2",
"eslint-plugin-svelte": "^2.46.1", "eslint-plugin-svelte": "^2.46.1",
"globals": "^15.15.0", "globals": "^15.15.0",
"postcss": "^8.5.6", "postcss": "^8.5.15",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1", "prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.48.0", "svelte": "^5.55.9",
"svelte-check": "^4.3.5", "svelte-check": "^4.3.5",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.53.1", "typescript-eslint": "^8.53.1",
"vite": "^5.4.21" "vite": "^6.4.2"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -60,26 +61,28 @@
"@capawesome/capacitor-badge": "^8.0.0", "@capawesome/capacitor-badge": "^8.0.0",
"@getalby/lightning-tools": "^6.1.0", "@getalby/lightning-tools": "^6.1.0",
"@getalby/sdk": "^5.1.2", "@getalby/sdk": "^5.1.2",
"@hono/node-server": "^2.0.0",
"@noble/curves": "^1.9.7", "@noble/curves": "^1.9.7",
"@pomade/core": "^0.2.3", "@pomade/core": "^0.2.5",
"@poppanator/sveltekit-svg": "^4.2.1", "@poppanator/sveltekit-svg": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2", "@tiptap/core": "^2.27.2",
"@tiptap/pm": "^2.27.2", "@tiptap/pm": "^2.27.2",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/throttle-debounce": "^5.0.2", "@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6", "@vite-pwa/assets-generator": "^1.0.2",
"@vite-pwa/sveltekit": "^0.6.8", "@vite-pwa/sveltekit": "^1.1.0",
"@welshman/app": "^0.8.13", "@welshman/app": "^0.8.16",
"@welshman/content": "^0.8.13", "@welshman/content": "^0.8.16",
"@welshman/editor": "^0.8.13", "@welshman/editor": "^0.8.16",
"@welshman/feeds": "^0.8.13", "@welshman/feeds": "^0.8.16",
"@welshman/lib": "^0.8.13", "@welshman/lib": "^0.8.16",
"@welshman/net": "^0.8.13", "@welshman/net": "^0.8.16",
"@welshman/router": "^0.8.13", "@welshman/router": "^0.8.16",
"@welshman/signer": "^0.8.13", "@welshman/signer": "^0.8.16",
"@welshman/store": "^0.8.13", "@welshman/store": "^0.8.16",
"@welshman/util": "^0.8.13", "@welshman/util": "^0.8.16",
"cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2", "compressorjs-next": "^1.1.2",
"daisyui": "^5.5.19", "daisyui": "^5.5.19",
"date-picker-svelte": "^2.17.0", "date-picker-svelte": "^2.17.0",
@@ -87,6 +90,7 @@
"emoji-picker-element": "^1.28.1", "emoji-picker-element": "^1.28.1",
"emoji-picker-element-data": "^1.8.0", "emoji-picker-element-data": "^1.8.0",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"hono": "^4.12.23",
"husky": "^9.1.7", "husky": "^9.1.7",
"idb": "^8.0.3", "idb": "^8.0.3",
"livekit-client": "^2.17.2", "livekit-client": "^2.17.2",
@@ -98,17 +102,5 @@
"throttle-debounce": "^5.0.2", "throttle-debounce": "^5.0.2",
"tippy.js": "^6.3.7" "tippy.js": "^6.3.7"
}, },
"pnpm": { "packageManager": "pnpm@11.5.1"
"ignoredBuiltDependencies": [
"esbuild"
],
"onlyBuiltDependencies": [
"sharp",
"nostr-signer-capacitor-plugin"
],
"overrides": {
"sharp": "0.35.0-rc.0"
}
},
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
} }
+27
View File
@@ -0,0 +1,27 @@
import {defineConfig, devices} from "@playwright/test"
// E2E tests live in ./e2e and run against the dev server (port 1847 from vite.config.ts).
// Run with `pnpm test:e2e` (after `pnpm exec playwright install` to fetch browsers).
export default defineConfig({
testDir: "e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: "html",
use: {
baseURL: "http://localhost:1847",
trace: "on-first-retry",
},
// Boots the SvelteKit dev server before the suite and reuses one if already running locally.
webServer: {
command: "pnpm dev",
url: "http://localhost:1847",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [
{name: "chromium", use: {...devices["Desktop Chrome"]}},
{name: "firefox", use: {...devices["Desktop Firefox"]}},
{name: "webkit", use: {...devices["Desktop Safari"]}},
],
})
+2645 -2776
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
allowBuilds:
nostr-signer-capacitor-plugin: true
cbor-extract: false
esbuild: false
sharp: true
minimumReleaseAgeExclude:
- '@pomade/core'
- '@welshman/app'
- '@welshman/content'
- '@welshman/editor'
- '@welshman/feeds'
- '@welshman/lib'
- '@welshman/net'
- '@welshman/router'
- '@welshman/signer'
- '@welshman/store'
- '@welshman/util'
overrides:
sharp: 0.35.0-rc.0
-2
View File
@@ -1,2 +0,0 @@
[toolchain]
channel = "1.92.0"
+288
View File
@@ -0,0 +1,288 @@
import path from "node:path"
import {promises as fs} from "node:fs"
import {fileURLToPath} from "node:url"
import "dotenv/config"
import {serve} from "@hono/node-server"
import {serveStatic} from "@hono/node-server/serve-static"
import {loadRelay} from "@welshman/app"
import {displayRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {load} from "cheerio"
import {Hono} from "hono"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const BUILD_DIR = path.join(__dirname, "build")
const INDEX_PATH = path.join(BUILD_DIR, "index.html")
const PORT = parseInt(process.env.PORT || "", 10) || 3000
const HOST = process.env.HOST || "0.0.0.0"
let TEMPLATE_HTML = ""
try {
TEMPLATE_HTML = await fs.readFile(INDEX_PATH, "utf8")
} catch (error) {
console.error(`Unable to read ${INDEX_PATH}. Run "pnpm run build" first.`)
process.exit(1)
}
const PLATFORM_NAME = process.env.VITE_PLATFORM_NAME
const PLATFORM_DESCRIPTION = process.env.VITE_PLATFORM_DESCRIPTION
// Match client-side decode logic
const decodeRelay = url => {
try {
return normalizeRelayUrl(decodeURIComponent(url))
} catch {
return undefined
}
}
const requestUrlFromContext = context => {
const requestUrl = new URL(context.req.url)
const forwardedProto = context.req.header("x-forwarded-proto")?.split(",")[0]?.trim()
const forwardedHost = context.req.header("x-forwarded-host")?.split(",")[0]?.trim()
if (forwardedProto === "http" || forwardedProto === "https") {
requestUrl.protocol = `${forwardedProto}:`
}
if (forwardedHost) {
requestUrl.host = forwardedHost
}
return requestUrl
}
const fetchRelayMeta = async relayUrl => {
if (!relayUrl) return undefined
try {
return await loadRelay(normalizeRelayUrl(relayUrl))
} catch (err) {
console.error(`Failed to fetch relay metadata for ${relayUrl}:`, err)
return undefined
}
}
const buildDefaultImage = requestUrl => {
return new URL("/maskable-icon-512x512.png", requestUrl.origin).toString()
}
const getMetadataForInvite = async (url, match) => {
const relayParam = url.searchParams.get("r")
if (!relayParam) return undefined
const relayMetadata = await fetchRelayMeta(relayParam)
if (!relayMetadata) return undefined
const relayDisplay = displayRelayUrl(relayParam)
const spaceName = relayMetadata.name
const relayDescription = relayMetadata.description
const title = spaceName
? `Invite to ${spaceName} on ${PLATFORM_NAME}`
: `Invite to a Space on ${PLATFORM_NAME}`
const parts = []
if (spaceName) {
parts.push(`You are invited to join ${spaceName} on ${PLATFORM_NAME}.`)
} else {
parts.push(`You are invited to join a space on ${PLATFORM_NAME}.`)
}
if (relayDisplay) parts.push(`Relay: ${relayDisplay}.`)
if (relayDescription) parts.push(relayDescription)
else parts.push(PLATFORM_DESCRIPTION)
const description = parts.join(" ")
const image =
relayMetadata.icon ||
relayMetadata.picture ||
relayMetadata.image ||
buildDefaultImage(url)
return {
title,
description,
image,
url: url.toString(),
site: url.origin,
}
}
const getMetadataForSpace = async (url, match) => {
const relayParam = decodeRelay(match[1])
if (!relayParam) return undefined
const relayMetadata = await fetchRelayMeta(relayParam)
if (!relayMetadata) return undefined
const spaceName = relayMetadata.name || displayRelayUrl(relayParam)
return {
title: `${spaceName} on ${PLATFORM_NAME}`,
description: relayMetadata.description || PLATFORM_DESCRIPTION,
image:
relayMetadata.icon ||
relayMetadata.picture ||
relayMetadata.image ||
buildDefaultImage(url),
url: url.toString(),
site: url.origin,
}
}
const getMetadataForSpaceSection = async (url, match) => {
const spaceMeta = await getMetadataForSpace(url, match)
if (!spaceMeta) return undefined
const section = match[2]
const sectionName = section.charAt(0).toUpperCase() + section.slice(1)
spaceMeta.title = `${sectionName} on ${spaceMeta.title}`
return spaceMeta
}
const getMetadataForSpaceItem = async (url, match) => {
const spaceMeta = await getMetadataForSpace(url, match)
if (!spaceMeta) return undefined
const section = match[2]
let itemType = "Item"
if (section === "calendar") itemType = "Event"
if (section === "threads") itemType = "Thread"
if (section === "polls") itemType = "Poll"
if (section === "goals") itemType = "Goal"
if (section === "classifieds") itemType = "Listing"
spaceMeta.title = `${itemType} on ${spaceMeta.title}`
return spaceMeta
}
const getMetadataForRoom = async (url, match) => {
const spaceMeta = await getMetadataForSpace(url, match)
if (!spaceMeta) return undefined
// Room metadata requires fetching from Nostr, which can be added later.
spaceMeta.title = `Room on ${spaceMeta.title}`
return spaceMeta
}
const routes = [
[/^\/join\/?$/, getMetadataForInvite],
[/^\/spaces\/([^/]+)\/(calendar|chat|threads|polls|goals|classifieds|recent)\/?$/, getMetadataForSpaceSection],
[/^\/spaces\/([^/]+)\/(calendar|threads|polls|goals|classifieds)\/([^/]+)\/?$/, getMetadataForSpaceItem],
[/^\/spaces\/([^/]+)\/([^/]+)\/?$/, getMetadataForRoom],
[/^\/spaces\/([^/]+)\/?$/, getMetadataForSpace],
]
const getMetadataForRoute = async url => {
for (const [regex, getMetadata] of routes) {
const match = url.pathname.match(regex)
if (match) {
try {
return await getMetadata(url, match)
} catch (err) {
console.error(`Error generating metadata for route ${url.pathname}:`, err)
return undefined
}
}
}
return undefined
}
const injectMeta = metadata => {
const $ = load(TEMPLATE_HTML)
if (metadata.title) {
$("title").text(metadata.title)
$('meta[property="og:title"]').attr("content", metadata.title)
$('meta[name="twitter:title"]').attr("content", metadata.title)
}
if (metadata.description) {
$('meta[name="description"]').attr("content", metadata.description)
$('meta[property="og:description"]').attr("content", metadata.description)
$('meta[name="twitter:description"]').attr("content", metadata.description)
}
if (metadata.image) {
$('meta[property="og:image"]').attr("content", metadata.image)
$('meta[name="twitter:image"]').attr("content", metadata.image)
}
if (metadata.url) {
$('meta[property="og:url"]').attr("content", metadata.url)
$('meta[name="twitter:site"]').attr("content", metadata.site)
$('meta[name="twitter:url"]').attr("content", metadata.url)
$('link[rel="canonical"]').attr("href", metadata.url)
}
return $.html()
}
const app = new Hono()
// Only allow GET and HEAD requests
app.use("*", async (context, next) => {
const method = context.req.method
if (method !== "GET" && method !== "HEAD") {
return context.text("Method Not Allowed", 405, {Allow: "GET, HEAD"})
}
await next()
})
// Serve static assets with appropriate caching
app.use(
"*",
serveStatic({
root: BUILD_DIR,
onFound: (filePath, context) => {
const isImmutable = filePath.split(path.sep).join("/").includes("/_app/immutable/")
const cacheControl =
path.basename(filePath) === "index.html"
? "no-cache"
: isImmutable
? "public, max-age=31536000, immutable"
: "public, max-age=3600"
context.header("Cache-Control", cacheControl)
// Immutable assets are content-hashed by Vite, so the filename is itself a
// stable content identifier. Exposing it as an ETag lets clients that
// revalidate explicitly (e.g. emoji-picker-element checks its data source
// on every load) skip re-downloading large files when nothing changed.
if (isImmutable) {
context.header("ETag", `"${path.basename(filePath)}"`)
}
},
}),
)
// SPA fallback for routes that don't match static files
app.get("*", async context => {
const requestUrl = requestUrlFromContext(context)
// If the path has an extension, it's likely a missing static asset, not an SPA route
if (path.extname(requestUrl.pathname)) {
return context.text("Not found", 404)
}
const metadata = await getMetadataForRoute(requestUrl)
const html = metadata ? injectMeta(metadata) : TEMPLATE_HTML
return context.html(html, 200, {
"Cache-Control": metadata ? "no-store" : "no-cache",
})
})
serve(
{
fetch: app.fetch,
hostname: HOST,
port: PORT,
},
() => {
console.log(`Flotilla server running on http://${HOST}:${PORT}`)
},
)
-4784
View File
File diff suppressed because it is too large Load Diff
-18
View File
@@ -1,18 +0,0 @@
[package]
name = "flotilla"
version = "0.1.0"
edition = "2021"
[lib]
name = "flotilla_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.3", features = [] }
[dependencies]
tauri = { version = "2.9.5", features = [] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
-7
View File
@@ -1,7 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default desktop capability for the main window",
"windows": ["main"],
"permissions": ["core:default"]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 926 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

-2
View File
@@ -1,2 +0,0 @@
[toolchain]
channel = "1.92.0"
-6
View File
@@ -1,6 +0,0 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
-6
View File
@@ -1,6 +0,0 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
flotilla_lib::run();
}
-37
View File
@@ -1,37 +0,0 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Flotilla",
"mainBinaryName": "flotilla",
"identifier": "social.flotilla.app",
"build": {
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"devUrl": "http://localhost:1847",
"frontendDist": "../build"
},
"app": {
"security": {
"capabilities": ["default"]
},
"windows": [
{
"label": "main",
"title": "Flotilla",
"width": 1240,
"height": 775,
"resizable": true
}
]
},
"bundle": {
"active": false,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+14 -1
View File
@@ -235,6 +235,7 @@
:root { :root {
font-family: Lato; font-family: Lato;
text-size-adjust: 100%;
--sait: var(--safe-area-inset-top, env(safe-area-inset-top)); --sait: var(--safe-area-inset-top, env(safe-area-inset-top));
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom)); --saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
--sail: var(--safe-area-inset-left, env(safe-area-inset-left)); --sail: var(--safe-area-inset-left, env(safe-area-inset-left));
@@ -332,7 +333,7 @@
.input-editor .tiptap { .input-editor .tiptap {
--tiptap-object-bg: var(--color-base-200); --tiptap-object-bg: var(--color-base-200);
@apply input h-auto p-[.65rem]; @apply input block h-auto p-[.65rem];
} }
/* link-content, based on tiptap */ /* link-content, based on tiptap */
@@ -416,12 +417,24 @@ progress[value]::-webkit-progress-value {
@apply md:left-[calc(18.5rem+var(--sail))]; @apply md:left-[calc(18.5rem+var(--sail))];
} }
.left-content-full {
@apply md:left-[calc(3.5rem+var(--sail))];
}
/* Keyboard open state adjustments */ /* Keyboard open state adjustments */
body.keyboard-open {
--saib: 0px;
}
body.keyboard-open .hide-on-keyboard { body.keyboard-open .hide-on-keyboard {
display: none; display: none;
} }
body.keyboard-open .chat__compose {
margin-bottom: 0;
}
/* chat view */ /* chat view */
.chat__compose { .chat__compose {
+7 -4
View File
@@ -2,15 +2,18 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>{NAME}</title>
<link rel="canonical" href="{URL}" />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" /> content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="{ACCENT}" /> <meta name="theme-color" content="{ACCENT}" />
<meta name="description" content="{DESCRIPTION}" /> <meta name="description" content="{DESCRIPTION}" />
<meta name="og:url" content="{URL}" /> <meta property="og:url" content="{URL}" />
<meta name="og:type" content="website" /> <meta property="og:type" content="website" />
<meta name="og:title" content="{NAME}" /> <meta property="og:title" content="{NAME}" />
<meta name="og:description" content="{DESCRIPTION}" /> <meta property="og:description" content="{DESCRIPTION}" />
<meta property="og:image" content="" />
<meta name="twitter:card" content="summary" /> <meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="{URL}" /> <meta name="twitter:site" content="{URL}" />
<meta name="twitter:title" content="{NAME}" /> <meta name="twitter:title" content="{NAME}" />
+21 -3
View File
@@ -6,15 +6,22 @@ export type VoiceSession = {
url: string url: string
h: string h: string
room: LiveKitRoom room: LiveKitRoom
muted: boolean
cameraOn: boolean cameraOn: boolean
screenShareOn: boolean screenShareOn: boolean
} }
/** Mic mute state is separate so toggling it does not re-render video tiles. */
export const voiceMicMuted = writable(true)
export type Pubkey = string export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: string} export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
export type ParticipantMediaState = {
muted: boolean
cameraOn: boolean
}
export enum VoiceState { export enum VoiceState {
Joining = "joining", Joining = "joining",
Connected = "connected", Connected = "connected",
@@ -27,8 +34,6 @@ export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const currentVoiceRoom = writable<Room | undefined>(undefined) export const currentVoiceRoom = writable<Room | undefined>(undefined)
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined => export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined /^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
@@ -41,6 +46,19 @@ export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
export const speakingParticipants = writable<VoiceParticipant[]>([]) export const speakingParticipants = writable<VoiceParticipant[]>([])
export const participantMediaState = writable(new Map<string, ParticipantMediaState>())
export const mediaStateByIdentity = derived(
[participantMediaState, currentVoiceSession, voiceMicMuted],
([$media, $session, $micMuted]) =>
(identity: string) => {
if ($session?.room.localParticipant.identity === identity) {
return {muted: $micMuted, cameraOn: $session.cameraOn}
}
return $media.get(identity) ?? {muted: true, cameraOn: false}
},
)
export const isParticipantSpeaking = derived( export const isParticipantSpeaking = derived(
speakingParticipants, speakingParticipants,
$participants => (p: VoiceParticipant) => $participants => (p: VoiceParticipant) =>
+224 -50
View File
@@ -6,28 +6,31 @@ import {
DisconnectReason, DisconnectReason,
LocalParticipant, LocalParticipant,
LocalTrackPublication, LocalTrackPublication,
Participant,
Room as LiveKitRoom, Room as LiveKitRoom,
RoomEvent, RoomEvent,
Track, Track,
TrackPublication,
supportsAudioOutputSelection, supportsAudioOutputSelection,
type AudioCaptureOptions, type AudioCaptureOptions,
} from "livekit-client" } from "livekit-client"
import {derived, get} from "svelte/store" import {derived, get} from "svelte/store"
import {map, removeUndefined, uniqBy} from "@welshman/lib" import {map, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util" import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
import {signer} from "@welshman/app" import {signer} from "@welshman/app"
import {getLivekitEndpoint} from "$lib/livekit" import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, whenAborted, whenTimeout} from "$lib/util" import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
import { import {
currentVoiceRoom, currentVoiceRoom,
currentVoiceSession, currentVoiceSession,
voiceMicMuted,
participantFromLiveKitIdentity, participantFromLiveKitIdentity,
participantKey, participantKey,
participantPubkeyMap, participantMediaState,
pubkeyFromLiveKitIdentity,
speakingParticipants, speakingParticipants,
VoiceState, VoiceState,
type ParticipantMediaState,
type VoiceParticipant, type VoiceParticipant,
voiceState, voiceState,
} from "@app/call/stores" } from "@app/call/stores"
@@ -75,20 +78,51 @@ export const switchVoiceActiveDevice = async (
} }
} }
const addParticipant = (identity: string) => { const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
participantPubkeyMap.update(m => { muted: !participant.isMicrophoneEnabled,
cameraOn: participant.isCameraEnabled,
})
const deleteParticipant = (identity: string) => {
participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m])))
}
const syncParticipantMedia = (participant: Participant) => {
const state = participantMediaFrom(participant)
participantMediaState.update(m => {
const prev = m.get(participant.identity)
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
const next = new Map(m) const next = new Map(m)
next.set(identity, pubkeyFromLiveKitIdentity(identity) ?? "") next.set(participant.identity, state)
return next return next
}) })
} }
const deleteParticipant = (identity: string) => { // LiveKit does not emit ParticipantConnected/Disconnected during reconnect.
participantPubkeyMap.update(m => { const resyncAfterReconnect = (room: LiveKitRoom) => {
const next = new Map(m) if (room !== activeRoom) return
next.delete(identity)
return next const next = new Map<string, ParticipantMediaState>()
for (const p of [room.localParticipant, ...room.remoteParticipants.values()]) {
next.set(p.identity, participantMediaFrom(p))
}
participantMediaState.set(next)
const session = get(currentVoiceSession)
if (!session) return
const {localParticipant} = room
voiceMicMuted.set(!localParticipant.isMicrophoneEnabled)
currentVoiceSession.set({
...session,
cameraOn: localParticipant.isCameraEnabled,
screenShareOn: localParticipant.isScreenShareEnabled,
}) })
triggerVideoFeedCount()
}
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
syncParticipantMedia(participant)
} }
const fetchLivekitToken = async ( const fetchLivekitToken = async (
@@ -124,15 +158,15 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
// We use the livekit identity list while in a call, and fall back to the list in kind 39004. // We use the livekit identity list while in a call, and fall back to the list in kind 39004.
derived( derived(
[ [
participantPubkeyMap, participantMediaState,
currentVoiceRoom, currentVoiceRoom,
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]), deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
], ],
([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => { ([$participantMediaState, $currentVoiceRoom, $publishedParticipantList]) => {
const inCall = $participantPubkeyMap.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h) const inCall = $participantMediaState.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
if (inCall) { if (inCall) {
const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity) const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
return uniqBy((p: VoiceParticipant) => participantKey(p), participants) return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
} else { } else {
const latestEvent = $publishedParticipantList as TrustedEvent | undefined const latestEvent = $publishedParticipantList as TrustedEvent | undefined
@@ -152,6 +186,8 @@ const setUpMicrophone = async (
startMuted: boolean, startMuted: boolean,
preferredMicId: string | undefined, preferredMicId: string | undefined,
participant: LocalParticipant, participant: LocalParticipant,
signal?: AbortSignal,
settleSignal?: AbortSignal,
): Promise<boolean> => { ): Promise<boolean> => {
if (startMuted) { if (startMuted) {
return true return true
@@ -163,28 +199,100 @@ const setUpMicrophone = async (
capture = {deviceId: preferredMicId} capture = {deviceId: preferredMicId}
} }
try { try {
await participant.setMicrophoneEnabled(true, capture) await Promise.race([
participant.setMicrophoneEnabled(true, capture),
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
whenAborted(signal),
])
muted = false muted = false
} catch (e) { } catch (e) {
// Timeout or microphone rejection: join muted, the call is still usable. A
// genuine abort is surfaced to the caller so it can tear down the room.
if (e instanceof AbortError) throw e
if (!(e instanceof TimeoutError)) {
pushToast({theme: "error", message: "Could not access microphone"}) pushToast({theme: "error", message: "Could not access microphone"})
} }
}
return muted return muted
} }
const onRoomDisconnected = (reason?: DisconnectReason) => { // The room whose events are allowed to mutate shared state. Abandoned rooms
// (after switching calls or an engine reconnect give-up) must not clobber it.
let activeRoom: LiveKitRoom | undefined
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
let reconnectAttempt = 0
const clearReconnectSchedule = () => {
if (reconnectTimeout !== undefined) {
clearTimeout(reconnectTimeout)
reconnectTimeout = undefined
}
reconnectAttempt = 0
}
const attemptReconnect = async () => {
const target = get(currentVoiceRoom)
if (!target) return
try {
await joinVoiceRoom(target.url, target.h)
} catch {
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
pushToast({theme: "error", message: "Voice connection lost."})
clearReconnectSchedule()
return
}
scheduleReconnect()
}
}
const scheduleReconnect = () => {
if (reconnectTimeout !== undefined) return
if (!get(currentVoiceRoom)) return
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
pushToast({theme: "error", message: "Voice connection lost."})
return
}
const delay = RECONNECT_DELAYS[reconnectAttempt]!
reconnectAttempt++
reconnectTimeout = setTimeout(() => {
reconnectTimeout = undefined
void attemptReconnect()
}, delay)
}
const makeOnRoomReconnected = (room: LiveKitRoom) => () => {
if (room !== activeRoom) return
resyncAfterReconnect(room)
}
const makeOnRoomDisconnected = (room: LiveKitRoom) => (reason?: DisconnectReason) => {
// Ignore disconnects from rooms that are no longer the active session.
if (room !== activeRoom) return
activeRoom = undefined
room.removeAllListeners()
videoPrimaryTileKey.set(undefined) videoPrimaryTileKey.set(undefined)
voiceMicMuted.set(true)
currentVoiceSession.set(undefined) currentVoiceSession.set(undefined)
resetVideoCallLayout() resetVideoCallLayout()
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set(VoiceState.Disconnected) voiceState.set(VoiceState.Disconnected)
const message = if (reason === DisconnectReason.JOIN_FAILURE) {
reason === DisconnectReason.JOIN_FAILURE pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
? "Could not connect to voice room. Please try again." } else if (get(currentVoiceRoom)) {
: "Voice connection lost." clearReconnectSchedule()
pushToast({theme: "error", message}) scheduleReconnect()
} else {
pushToast({theme: "error", message: "Voice connection lost."})
}
} }
speakingParticipants.set([]) speakingParticipants.set([])
participantPubkeyMap.set(new Map()) participantMediaState.set(new Map())
} }
const onTrackSubscribed = (track: Track) => { const onTrackSubscribed = (track: Track) => {
@@ -214,8 +322,8 @@ const playJoinSound = () => {
audio.play().catch(() => {}) audio.play().catch(() => {})
} }
const onParticipantConnected = (participant: {identity: string}) => { const onParticipantConnected = (participant: Participant) => {
addParticipant(participant.identity) syncParticipantMedia(participant)
playJoinSound() playJoinSound()
} }
@@ -236,20 +344,22 @@ const onLocalTrackUnpublished = (
let joinAbortController: AbortController | undefined let joinAbortController: AbortController | undefined
export const cancelJoinVoiceRoom = () => { const abortJoinVoiceRoom = () => {
joinAbortController?.abort() joinAbortController?.abort()
} }
export const cancelJoinVoiceRoom = () => {
clearReconnectSchedule()
abortJoinVoiceRoom()
}
export const joinVoiceRoom = async ( export const joinVoiceRoom = async (
url: string, url: string,
h: string, h: string,
startMuted = true, startMuted = true,
preferredMicId?: string, preferredMicId?: string,
): Promise<void> => { ): Promise<void> => {
cancelJoinVoiceRoom() abortJoinVoiceRoom()
const session = get(currentVoiceSession)
if (session) await leaveVoiceRoom()
currentVoiceRoom.set(get(deriveRoom(url, h))) currentVoiceRoom.set(get(deriveRoom(url, h)))
voiceState.set(VoiceState.Joining) voiceState.set(VoiceState.Joining)
@@ -259,62 +369,123 @@ export const joinVoiceRoom = async (
const signal = controller.signal const signal = controller.signal
const isActive = () => joinAbortController === controller const isActive = () => joinAbortController === controller
// Self-cleaning controller: aborted in finally so whenTimeout/whenAborted
// helpers clear their timers/listeners once the races below have settled.
const settle = new AbortController()
try { try {
const {server_url, participant_token} = await fetchLivekitToken(url, h, signal) // Tear down any existing session before joining. Bound it so a slow leave
// (camera/screenshare renegotiation can take ~15s) cannot block this join.
if (get(currentVoiceSession)) {
await Promise.race([
leaveVoiceRoom(),
whenTimeout(15_000, {message: "Leaving previous call timed out.", signal: settle.signal}),
whenAborted(signal),
]).catch(e => {
if (e instanceof AbortError) throw e
})
// leaveVoiceRoom flips voiceState to Disconnected; re-assert Joining.
voiceState.set(VoiceState.Joining)
}
if (signal.aborted) throw new AbortError()
const {server_url, participant_token} = await Promise.race([
fetchLivekitToken(url, h, signal),
whenTimeout(15_000, {
message: "Connection timed out. Please check your network and try again.",
signal: settle.signal,
}),
whenAborted(signal),
])
if (signal.aborted) throw new AbortError() if (signal.aborted) throw new AbortError()
const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true}) const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true})
activeRoom = liveKitRoom
liveKitRoom.on(RoomEvent.Disconnected, onRoomDisconnected) liveKitRoom.on(RoomEvent.Disconnected, makeOnRoomDisconnected(liveKitRoom))
liveKitRoom.on(RoomEvent.Reconnected, makeOnRoomReconnected(liveKitRoom))
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected) liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected) liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed) liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished) liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
liveKitRoom.on(RoomEvent.TrackMuted, onParticipantMediaChanged)
liveKitRoom.on(RoomEvent.TrackUnmuted, onParticipantMediaChanged)
liveKitRoom.on(RoomEvent.TrackPublished, onParticipantMediaChanged)
liveKitRoom.on(RoomEvent.TrackUnpublished, onParticipantMediaChanged)
liveKitRoom.on(RoomEvent.LocalTrackPublished, onParticipantMediaChanged)
try { try {
await Promise.race([ await Promise.race([
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}), liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}),
whenTimeout(15_000, { whenTimeout(15_000, {
message: "Connection timed out. Please check your network and try again.", message: "Connection timed out. Please check your network and try again.",
signal: settle.signal,
}), }),
whenAborted(signal), whenAborted(signal),
]) ])
} catch (e) { } catch (e) {
if (activeRoom === liveKitRoom) activeRoom = undefined
liveKitRoom.removeAllListeners()
liveKitRoom.disconnect() liveKitRoom.disconnect()
throw e throw e
} }
participantPubkeyMap.set(new Map()) participantMediaState.set(new Map())
addParticipant(liveKitRoom.localParticipant.identity) syncParticipantMedia(liveKitRoom.localParticipant)
for (const p of liveKitRoom.remoteParticipants.values()) { for (const p of liveKitRoom.remoteParticipants.values()) {
addParticipant(p.identity) syncParticipantMedia(p)
} }
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant) // Bounded against timeout/abort inside setUpMicrophone: a stuck permission
// prompt resolves to muted rather than hanging the join forever.
const muted = await setUpMicrophone(
startMuted,
preferredMicId,
liveKitRoom.localParticipant,
signal,
settle.signal,
)
// A cancel during the mic step must tear down the connected room rather
// than leaking it.
if (signal.aborted) {
if (activeRoom === liveKitRoom) activeRoom = undefined
liveKitRoom.removeAllListeners()
liveKitRoom.disconnect()
throw new AbortError()
}
voiceMicMuted.set(muted)
currentVoiceSession.set({ currentVoiceSession.set({
url, url,
h, h,
room: liveKitRoom, room: liveKitRoom,
muted,
cameraOn: false, cameraOn: false,
screenShareOn: false, screenShareOn: false,
}) })
voiceState.set(VoiceState.Connected) voiceState.set(VoiceState.Connected)
clearReconnectSchedule()
playJoinSound() playJoinSound()
} catch (e) { } catch (e) {
if (isActive()) voiceState.set(VoiceState.Disconnected) if (isActive()) voiceState.set(VoiceState.Disconnected)
if (e instanceof AbortError) return if (e instanceof AbortError) {
clearReconnectSchedule()
return
}
throw e throw e
} finally { } finally {
settle.abort()
if (isActive()) joinAbortController = undefined if (isActive()) joinAbortController = undefined
} }
} }
export const leaveVoiceRoom = async () => { export const leaveVoiceRoom = async () => {
clearReconnectSchedule()
const session = get(currentVoiceSession) const session = get(currentVoiceSession)
if (!session) return if (!session) return
@@ -337,37 +508,40 @@ export const leaveVoiceRoom = async () => {
} }
} }
// Always tear down this room's connection and listeners.
if (activeRoom === session.room) activeRoom = undefined
session.room.removeAllListeners()
session.room.disconnect()
// Only reset shared UI state if this session is still current. A slow leave
// that was superseded by a new join (bounded by a timeout in joinVoiceRoom)
// must not clobber the freshly-joined session when it finally completes.
if (get(currentVoiceSession) === session) {
voiceState.set(VoiceState.Disconnected) voiceState.set(VoiceState.Disconnected)
videoPrimaryTileKey.set(undefined) videoPrimaryTileKey.set(undefined)
voiceMicMuted.set(true)
currentVoiceSession.set(undefined) currentVoiceSession.set(undefined)
resetVideoCallLayout() resetVideoCallLayout()
session.room.disconnect()
speakingParticipants.set([]) speakingParticipants.set([])
participantPubkeyMap.set(new Map()) participantMediaState.set(new Map())
} }
export const rejoinVoiceRoom = async (): Promise<void> => {
const target = get(currentVoiceRoom)
if (!target) return
return joinVoiceRoom(target.url, target.h)
} }
export const toggleMute = async () => { export const toggleMute = async () => {
const session = get(currentVoiceSession) const session = get(currentVoiceSession)
if (!session) return if (!session) return
const muted = !session.muted voiceMicMuted.update(not)
if (muted) { if (get(voiceMicMuted)) {
// Disable and re-enable microphone to trigger permission prompt // Disable and re-enable microphone to trigger permission prompt
session.room.localParticipant.setMicrophoneEnabled(false) session.room.localParticipant.setMicrophoneEnabled(false)
currentVoiceSession.set({...session, muted})
return return
} }
try { try {
await session.room.localParticipant.setMicrophoneEnabled(true) await session.room.localParticipant.setMicrophoneEnabled(true)
currentVoiceSession.set({...session, muted})
} catch (e) { } catch (e) {
voiceMicMuted.set(true)
pushToast({theme: "error", message: "Could not access microphone"}) pushToast({theme: "error", message: "Could not access microphone"})
} }
} }
@@ -19,15 +19,17 @@
const end = $derived(parseInt(meta.end)) const end = $derived(parseInt(meta.end))
</script> </script>
<div class="flex grow flex-wrap justify-between gap-2"> <div class="flex flex-col justify-between gap-1">
<p class="text-xl">{meta.title || meta.name}</p> <p class="text-lg">{meta.title || meta.name}</p>
{#if !isNaN(start) && !isNaN(end)} {#if !isNaN(start) && !isNaN(end)}
{@const startDateDisplay = formatTimestampAsDate(start)} {@const startDateDisplay = formatTimestampAsDate(start)}
{@const endDateDisplay = formatTimestampAsDate(end)} {@const endDateDisplay = formatTimestampAsDate(end)}
{@const isSingleDay = startDateDisplay === endDateDisplay} {@const isSingleDay = startDateDisplay === endDateDisplay}
<div class="flex items-center gap-2 text-sm"> <div class="flex flex-wrap gap-2 text-xs">
<div class="flex items-center gap-2">
<Icon icon={ClockCircle} size={4} /> <Icon icon={ClockCircle} size={4} />
<span class="hidden sm:block">{formatTimestampAsDate(start)}</span> {formatTimestampAsDate(start)}
</div>
{formatTimestampAsTime(start)}{isSingleDay {formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end) ? formatTimestampAsTime(end)
: formatTimestamp(end)} : formatTimestamp(end)}
+4 -3
View File
@@ -53,7 +53,7 @@
import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte" import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte" import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte" import ThunkToast from "@app/components/ThunkToast.svelte"
import {userSettingsValues, deriveChat} from "@app/core/state" import {userSettingsValues, deriveChat, makeChatId} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {DraftKey} from "@app/util/drafts" import {DraftKey} from "@app/util/drafts"
import {makeDelete, prependParent} from "@app/core/commands" import {makeDelete, prependParent} from "@app/core/commands"
@@ -66,8 +66,9 @@
const {pubkeys, info}: Props = $props() const {pubkeys, info}: Props = $props()
const chat = deriveChat(pubkeys) const chatId = makeChatId(pubkeys)
const draftKey = new DraftKey<{content?: string | object}>(`dm:${$chat?.id}`) const chat = deriveChat(chatId)
const draftKey = new DraftKey<{content?: string | object}>(`dm:${chatId}`)
const others = remove($pubkey!, pubkeys) const others = remove($pubkey!, pubkeys)
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk))) const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
+4
View File
@@ -6,6 +6,7 @@
truncate, truncate,
renderAsHtml, renderAsHtml,
isText, isText,
isEmail,
isEmoji, isEmoji,
isTopic, isTopic,
isCode, isCode,
@@ -26,6 +27,7 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ContentToken from "@app/components/ContentToken.svelte" import ContentToken from "@app/components/ContentToken.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte" import ContentEmoji from "@app/components/ContentEmoji.svelte"
import ContentEmail from "@app/components/ContentEmail.svelte"
import ContentCode from "@app/components/ContentCode.svelte" import ContentCode from "@app/components/ContentCode.svelte"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte" import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte" import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
@@ -159,6 +161,8 @@
<ContentTopic value={parsed.value} /> <ContentTopic value={parsed.value} />
{:else if isEmoji(parsed)} {:else if isEmoji(parsed)}
<ContentEmoji value={parsed.value} /> <ContentEmoji value={parsed.value} />
{:else if isEmail(parsed)}
<ContentEmail value={parsed.value} />
{:else if isCode(parsed)} {:else if isCode(parsed)}
<ContentCode <ContentCode
value={parsed.value} value={parsed.value}
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
export let value: string
</script>
<Link external href="mailto:{value}">
<Icon icon={LinkRound} size={3} />
{value}
</Link>
+4
View File
@@ -7,6 +7,7 @@
renderAsHtml, renderAsHtml,
isText, isText,
isEmoji, isEmoji,
isEmail,
isTopic, isTopic,
isCode, isCode,
isCashu, isCashu,
@@ -24,6 +25,7 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ContentToken from "@app/components/ContentToken.svelte" import ContentToken from "@app/components/ContentToken.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte" import ContentEmoji from "@app/components/ContentEmoji.svelte"
import ContentEmail from "@app/components/ContentEmail.svelte"
import ContentCode from "@app/components/ContentCode.svelte" import ContentCode from "@app/components/ContentCode.svelte"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte" import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
import ContentNewline from "@app/components/ContentNewline.svelte" import ContentNewline from "@app/components/ContentNewline.svelte"
@@ -109,6 +111,8 @@
<ContentTopic value={parsed.value} /> <ContentTopic value={parsed.value} />
{:else if isEmoji(parsed)} {:else if isEmoji(parsed)}
<ContentEmoji value={parsed.value} /> <ContentEmoji value={parsed.value} />
{:else if isEmail(parsed)}
<ContentEmail value={parsed.value} />
{:else if isCode(parsed)} {:else if isCode(parsed)}
<ContentCode <ContentCode
value={parsed.value} value={parsed.value}
@@ -13,13 +13,16 @@
const onClick = () => goToSpace(url) const onClick = () => goToSpace(url)
const path = makeSpacePath(url)
const display = $derived(deriveRelayDisplay(url)) const display = $derived(deriveRelayDisplay(url))
</script> </script>
<PrimaryNavItem <PrimaryNavItem
href={path}
onclick={onClick} onclick={onClick}
title={$display} title={$display}
class="tooltip-right" class="tooltip-right"
notification={$notifications.has(makeSpacePath(url))}> notification={$notifications.has(path)}>
<RelayIcon {url} size={10} class="rounded-full" /> <RelayIcon {url} size={10} class="rounded-full" />
</PrimaryNavItem> </PrimaryNavItem>
+5 -7
View File
@@ -19,12 +19,12 @@
<div class="col-4 text-left"> <div class="col-4 text-left">
<div class="col-2"> <div class="col-2">
<div class="relative flex gap-4"> <div class="relative flex gap-2 sm:gap-4">
<div class="relative"> <div class="relative">
<div class="avatar relative"> <div class="avatar relative">
<div <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-12 w-12 min-w-12 rounded-full border-2 border-solid border-base-300 bg-base-300">
<RelayIcon {url} /> <RelayIcon {url} size={10} />
</div> </div>
</div> </div>
{#if $rooms.includes(url)} {#if $rooms.includes(url)}
@@ -36,13 +36,11 @@
{/if} {/if}
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<h2 class="ellipsize whitespace-nowrap text-xl"> <RelayName {url} class="ellipsize whitespace-nowrap text-lg sm:text-xl" />
<RelayName {url} /> <p class="text-xs sm:text-sm opacity-75">{url}</p>
</h2>
<p class="text-sm opacity-75">{url}</p>
</div> </div>
</div> </div>
<RelayDescription {url} /> <RelayDescription {url} class="text-sm sm:text-md" />
</div> </div>
{#if !hideFavorites && $favorited.size > 0} {#if !hideFavorites && $favorited.size > 0}
<div class="row-2 card2 card2-sm bg-alt"> <div class="row-2 card2 card2-sm bg-alt">
-2
View File
@@ -68,8 +68,6 @@
const content = ed.getText({blockSeparator: "\n"}).trim() const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = ed.storage.nostr.getEditorTags() const tags = ed.storage.nostr.getEditorTags()
if (!content) return
onSubmit({content, tags}) onSubmit({content, tags})
draftKey?.clear() draftKey?.clear()
+1
View File
@@ -122,6 +122,7 @@
repository.removeEvent(thunk.event.id) repository.removeEvent(thunk.event.id)
pushToast({theme: "error", message}) pushToast({theme: "error", message})
} else { } else {
await removeRoomMembership(url, h)
goto(makeSpacePath(url)) goto(makeSpacePath(url))
} }
}, },
+2 -5
View File
@@ -11,7 +11,6 @@
import Modal from "@lib/components/Modal.svelte" import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import LogIn from "@app/components/LogIn.svelte" import LogIn from "@app/components/LogIn.svelte"
import InfoNostr from "@app/components/InfoNostr.svelte"
import SignUpKey from "@app/components/SignUpKey.svelte" import SignUpKey from "@app/components/SignUpKey.svelte"
import SignUpEmail from "@app/components/SignUpEmail.svelte" import SignUpEmail from "@app/components/SignUpEmail.svelte"
import SignUpProfile from "@app/components/SignUpProfile.svelte" import SignUpProfile from "@app/components/SignUpProfile.svelte"
@@ -91,11 +90,9 @@
<Modal> <Modal>
<ModalBody> <ModalBody>
<h1 class="heading">Sign up with Nostr</h1> <h1 class="heading">Join {PLATFORM_NAME}</h1>
<p class="m-auto max-w-sm text-center"> <p class="m-auto max-w-sm text-center">
{PLATFORM_NAME} is built using the Censorship resistant digital spaces for communities. Meet new people, own your identity.
<Button class="link" onclick={() => pushModal(InfoNostr)}>nostr protocol</Button>, which gives
users control over their digital identity using <strong>cryptographic key pairs</strong>.
</p> </p>
{#if hasPomade} {#if hasPomade}
<Button onclick={flows.email.start} class="btn btn-primary"> <Button onclick={flows.email.start} class="btn btn-primary">
+7 -1
View File
@@ -7,17 +7,19 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import PageBar from "@lib/components/PageBar.svelte" import PageBar from "@lib/components/PageBar.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import {decodeRelay} from "@app/core/state" import {decodeRelay} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes" import {makeSpacePath} from "@app/util/routes"
interface Props { interface Props {
back?: () => unknown back?: () => unknown
leading?: Snippet
title?: Snippet title?: Snippet
action?: Snippet action?: Snippet
[key: string]: any [key: string]: any
} }
const {back = () => goto(makeSpacePath(url)), title, action, ...props}: Props = $props() const {back = () => goto(makeSpacePath(url)), leading, title, action, ...props}: Props = $props()
const url = decodeRelay($page.params.relay!) const url = decodeRelay($page.params.relay!)
</script> </script>
@@ -30,6 +32,10 @@
<div class="ellipsize whitespace-nowrap flex grow items-center justify-between gap-4"> <div class="ellipsize whitespace-nowrap flex grow items-center justify-between gap-4">
<div class="flex flex-col"> <div class="flex flex-col">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<RelayIcon {url} size={5} class="rounded-full md:hidden" />
<div class="hidden md:contents">
{@render leading?.()}
</div>
{@render title?.()} {@render title?.()}
</div> </div>
<div class="text-xs text-primary md:hidden"> <div class="text-xs text-primary md:hidden">
+1 -1
View File
@@ -23,8 +23,8 @@
import SpaceJoinSettings from "@app/components/SpaceJoinSettings.svelte" import SpaceJoinSettings from "@app/components/SpaceJoinSettings.svelte"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {makeSpacePath} from "@app/util/routes" import {makeSpacePath} from "@app/util/routes"
import {Push} from "@app/util/notifications"
import {relaysMostlyRestricted, notificationSettings, parseInviteLink} from "@app/core/state" import {relaysMostlyRestricted, notificationSettings, parseInviteLink} from "@app/core/state"
import {Push} from "@app/util/push"
import { import {
attemptRelayAccess, attemptRelayAccess,
addSpaceMembership, addSpaceMembership,
+1 -1
View File
@@ -24,7 +24,7 @@
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {makeSpacePath} from "@app/util/routes" import {makeSpacePath} from "@app/util/routes"
import {Push} from "@app/util/notifications" import {Push} from "@app/util/push"
type Props = { type Props = {
url: string url: string
+2 -5
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL} from "@welshman/util" import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL} from "@welshman/util"
import {deriveRelay, deriveRelayDisplay, createSearch, pubkey} from "@welshman/app" import {deriveRelay, createSearch, pubkey} from "@welshman/app"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl" import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
@@ -65,7 +65,6 @@
const {url} = $props() const {url} = $props()
const relay = deriveRelay(url) const relay = deriveRelay(url)
const display = deriveRelayDisplay(url)
const chatPath = makeSpacePath(url, "chat") const chatPath = makeSpacePath(url, "chat")
const goalsPath = makeSpacePath(url, "goals") const goalsPath = makeSpacePath(url, "goals")
const threadsPath = makeSpacePath(url, "threads") const threadsPath = makeSpacePath(url, "threads")
@@ -144,9 +143,7 @@
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100" class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
onclick={openMenu}> onclick={openMenu}>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<strong <strong class="flex items-center gap-1 relative">
class="flex items-center gap-1 relative tooltip tooltip-right"
data-tip={$display}>
<RelayName {url} class="ellipsize" /> <RelayName {url} class="ellipsize" />
<div <div
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0" class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
+5 -5
View File
@@ -26,22 +26,22 @@
{@const {pubkey, software, version, supported_nips, limitation} = $relay} {@const {pubkey, software, version, supported_nips, limitation} = $relay}
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#if pubkey} {#if pubkey}
<div class="badge badge-neutral"> <div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Administrator: <ProfileLink unstyled {pubkey} /></span> <span class="ellipsize">Administrator: <ProfileLink unstyled {pubkey} /></span>
</div> </div>
{/if} {/if}
{#if $relay?.contact} {#if $relay?.contact}
<div class="badge badge-neutral"> <div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Contact: {$relay.contact}</span> <span class="ellipsize">Contact: {$relay.contact}</span>
</div> </div>
{/if} {/if}
{#if software} {#if software}
<div class="badge badge-neutral"> <div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Software: {software}</span> <span class="ellipsize">Software: {software}</span>
</div> </div>
{/if} {/if}
{#if version} {#if version}
<div class="badge badge-neutral"> <div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Version: {version}</span> <span class="ellipsize">Version: {version}</span>
</div> </div>
{/if} {/if}
@@ -61,7 +61,7 @@
</p> </p>
{/if} {/if}
{#if limitation?.min_pow_difficulty} {#if limitation?.min_pow_difficulty}
<p class="badge badge-warning"> <p class="badge badge-warning text-wrap h-auto">
<span class="ellipsize">Min PoW: {limitation?.min_pow_difficulty}</span> <span class="ellipsize">Min PoW: {limitation?.min_pow_difficulty}</span>
</p> </p>
{/if} {/if}

Some files were not shown because too many files have changed in this diff Show More