Compare commits

..

1 Commits

Author SHA1 Message Date
userAdityaa 7a915eb71c fix: safari chat regressions 2026-05-03 15:24:33 +05:45
343 changed files with 11167 additions and 7696 deletions
+1
View File
@@ -4,6 +4,7 @@ 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,4 +15,3 @@ 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,17 +1,12 @@
name: Container Image Build and Publish name: Docker
on: on:
push: push:
branches: [master] branches: [master]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env: env:
REGISTRY: gitea.coracle.social REGISTRY: ghcr.io
IMAGE_NAME: coracle/flotilla IMAGE_NAME: coracle-social/flotilla
jobs: jobs:
build-and-push-image: build-and-push-image:
@@ -28,8 +23,8 @@ jobs:
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: hodlbod username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.PACKAGE_TOKEN }} password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
@@ -37,7 +32,6 @@ 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
@@ -51,7 +45,6 @@ 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 }}
+4 -7
View File
@@ -6,11 +6,6 @@
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
@@ -32,10 +27,13 @@ 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
@@ -73,7 +71,6 @@ GoogleService-Info.plist
.idea/ .idea/
.vscode/ .vscode/
.claude/ .claude/
.local/
# OS generated # OS generated
.DS_Store .DS_Store
-26
View File
@@ -1,31 +1,5 @@
# 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
+26 -25
View File
@@ -1,31 +1,32 @@
# Build and run the Flotilla web server. # Stage 1: Build
# # Uses .env from build context for config (logo, branding, etc.)
# docker build -t flotilla . # Optional: docker build --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) -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.
# https://pnpm.io/docker#example-3-build-on-cicd FROM node:20-bookworm AS builder
FROM node:24-slim AS builder
ENV PNPM_HOME="/pnpm" RUN apt-get update && apt-get install -y --no-install-recommends curl
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable RUN npm install -g pnpm@latest
WORKDIR /app WORKDIR /app
ENV NODE_OPTIONS=--max_old_space_size=16384
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN pnpm i --frozen-lockfile RUN pnpm i
COPY . .
ARG VITE_BUILD_HASH # Copy everything (including .env when present) - build.sh will source it
RUN pnpm run build COPY . .
RUN pnpm run build:server
ARG VITE_BUILD_HASH
ENV VITE_BUILD_HASH=${VITE_BUILD_HASH}
ENV NODE_OPTIONS=--max_old_space_size=16384
RUN pnpm run build
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 --from=builder /app/build-server/server.js /app/server.js # Copy only the built output - no source, no .env, no dev deps
EXPOSE 3000 COPY --from=builder /app/build ./build
USER node
CMD ["node", "server.js"] CMD ["npx", "serve", "-s", "build"]
+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
pnpm run start npx serve -s build
``` ```
Or, if you prefer to use a container: Or, if you prefer to use a container:
```sh ```sh
docker run -d -p 3000:3000 gitea.coracle.social/coracle/flotilla:latest podman run -d -p 3000:3000 ghcr.io/coracle-social/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
docker run -v ./mount:/app/mount gitea.coracle.social/coracle/flotilla:latest bash -c 'cp -r build/* mount' podman run -v ./mount:/app/mount ghcr.io/coracle-social/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 47 versionCode 46
versionName "1.8.0" versionName "1.7.4"
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_a3c0fb15d5bfa83f24d0070ca2583fc9/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_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin/android')
-12
View File
@@ -1,12 +0,0 @@
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
@@ -1,29 +0,0 @@
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
@@ -1,29 +0,0 @@
[
{
"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
@@ -1,30 +0,0 @@
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}
+11 -29
View File
@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 54; objectVersion = 48;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -131,9 +131,8 @@
504EC2FC1FED79650016851F /* Project object */ = { 504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 920; LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 2630; LastUpgradeCheck = 920;
TargetAttributes = { TargetAttributes = {
504EC3031FED79650016851F = { 504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2; CreatedOnToolsVersion = 9.2;
@@ -258,7 +257,6 @@
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;
@@ -266,10 +264,8 @@
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;
@@ -279,10 +275,8 @@
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;
@@ -301,7 +295,6 @@
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";
}; };
@@ -321,7 +314,6 @@
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;
@@ -329,10 +321,8 @@
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;
@@ -342,10 +332,8 @@
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;
@@ -357,9 +345,7 @@
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };
name = Release; name = Release;
@@ -372,16 +358,14 @@
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 = 38; CURRENT_PROJECT_VERSION = 37;
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 = ( LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
"$(inherited)", MARKETING_VERSION = 1.7.4;
"@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)";
@@ -401,16 +385,14 @@
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 = 38; CURRENT_PROJECT_VERSION = 37;
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 = ( LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
"$(inherited)", MARKETING_VERSION = 1.7.4;
"@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_a3c0fb15d5bfa83f24d0070ca2583fc9/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_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin'
end end
target 'Flotilla Chat' do target 'Flotilla Chat' do
-1
View File
@@ -12,7 +12,6 @@ if (execSync('git status --porcelain', { encoding: 'utf8' }).trim() && !force) {
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8')) const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
pkg.pnpm = pkg.pnpm || {}
pkg.pnpm.overrides = pkg.pnpm.overrides || {} pkg.pnpm.overrides = pkg.pnpm.overrides || {}
pkg.pnpm.overrides["@welshman/app"] = "link:../welshman/packages/app" pkg.pnpm.overrides["@welshman/app"] = "link:../welshman/packages/app"
pkg.pnpm.overrides["@welshman/content"] = "link:../welshman/packages/content" pkg.pnpm.overrides["@welshman/content"] = "link:../welshman/packages/content"
+38 -30
View File
@@ -1,18 +1,18 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "1.8.0", "version": "1.7.4",
"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,27 +20,26 @@
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@playwright/test": "^1.49.1", "@sveltejs/kit": "^2.50.1",
"@sveltejs/kit": "^2.61.1", "@sveltejs/vite-plugin-svelte": "^4.0.4",
"@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.15", "postcss": "^8.5.6",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1", "prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.55.9", "svelte": "^5.48.0",
"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": "^6.4.2" "vite": "^5.4.21"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -61,28 +60,26 @@
"@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.5", "@pomade/core": "^0.2.3",
"@poppanator/sveltekit-svg": "^7.0.0", "@poppanator/sveltekit-svg": "^4.2.1",
"@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": "^1.0.2", "@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^1.1.0", "@vite-pwa/sveltekit": "^0.6.8",
"@welshman/app": "^0.8.16", "@welshman/app": "^0.8.13",
"@welshman/content": "^0.8.16", "@welshman/content": "^0.8.13",
"@welshman/editor": "^0.8.16", "@welshman/editor": "^0.8.13",
"@welshman/feeds": "^0.8.16", "@welshman/feeds": "^0.8.13",
"@welshman/lib": "^0.8.16", "@welshman/lib": "^0.8.13",
"@welshman/net": "^0.8.16", "@welshman/net": "^0.8.13",
"@welshman/router": "^0.8.16", "@welshman/router": "^0.8.13",
"@welshman/signer": "^0.8.16", "@welshman/signer": "^0.8.13",
"@welshman/store": "^0.8.16", "@welshman/store": "^0.8.13",
"@welshman/util": "^0.8.16", "@welshman/util": "^0.8.13",
"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",
@@ -90,7 +87,6 @@
"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",
@@ -102,5 +98,17 @@
"throttle-debounce": "^5.0.2", "throttle-debounce": "^5.0.2",
"tippy.js": "^6.3.7" "tippy.js": "^6.3.7"
}, },
"packageManager": "pnpm@11.5.1" "pnpm": {
"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
@@ -1,27 +0,0 @@
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"]}},
],
})
+2776 -2645
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
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
@@ -0,0 +1,2 @@
[toolchain]
channel = "1.92.0"
-282
View File
@@ -1,282 +0,0 @@
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)
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
@@ -0,0 +1,18 @@
[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
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+7
View File
@@ -0,0 +1,7 @@
{
"$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.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,5 @@
<?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.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

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

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+2
View File
@@ -0,0 +1,2 @@
[toolchain]
channel = "1.92.0"
+6
View File
@@ -0,0 +1,6 @@
#[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
@@ -0,0 +1,6 @@
// 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
@@ -0,0 +1,37 @@
{
"$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"
]
}
}
+1 -14
View File
@@ -235,7 +235,6 @@
: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));
@@ -333,7 +332,7 @@
.input-editor .tiptap { .input-editor .tiptap {
--tiptap-object-bg: var(--color-base-200); --tiptap-object-bg: var(--color-base-200);
@apply input block h-auto p-[.65rem]; @apply input h-auto p-[.65rem];
} }
/* link-content, based on tiptap */ /* link-content, based on tiptap */
@@ -417,24 +416,12 @@ 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 {
+4 -7
View File
@@ -2,18 +2,15 @@
<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 property="og:url" content="{URL}" /> <meta name="og:url" content="{URL}" />
<meta property="og:type" content="website" /> <meta name="og:type" content="website" />
<meta property="og:title" content="{NAME}" /> <meta name="og:title" content="{NAME}" />
<meta property="og:description" content="{DESCRIPTION}" /> <meta name="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}" />
-89
View File
@@ -1,89 +0,0 @@
import {
REPORT,
ROOM_ADD_MEMBER,
ROOM_JOIN,
ROOM_LEAVE,
ROOM_MEMBERS,
ROOM_REMOVE_MEMBER,
getPubkeyTagValues,
getTagValue,
sortEventsDesc,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {first, groupBy, removeUndefined} from "@welshman/lib"
import {derived} from "svelte/store"
import {deriveEventsForUrl} from "@app/repository"
import {getRoomMembers} from "@app/members"
// Action items (admin review queue)
export const deriveSpaceActionItems = (url: string) =>
derived(
deriveEventsForUrl(url, [
{
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
},
]),
$events => {
const getRoomId = (e: TrustedEvent) =>
getTagValue(e.kind === ROOM_MEMBERS ? "d" : "h", e.tags)
const reports = $events.filter(e => e.kind === REPORT)
const pendingJoins: TrustedEvent[] = []
// Room-level join requests — most recent per pubkey+h
for (const [h, roomEvents] of groupBy(getRoomId, $events)) {
if (!h) continue
const roomJoins: TrustedEvent[] = []
const roomLeaves: TrustedEvent[] = []
const roomMembershipEvents: TrustedEvent[] = []
for (const event of roomEvents) {
switch (event.kind) {
case ROOM_JOIN:
roomJoins.push(event)
break
case ROOM_LEAVE:
roomLeaves.push(event)
break
case ROOM_MEMBERS:
case ROOM_ADD_MEMBER:
case ROOM_REMOVE_MEMBER:
roomMembershipEvents.push(event)
break
}
}
const roomMembers = new Set(getRoomMembers(url, h, roomMembershipEvents))
pendingJoins.push(
...removeUndefined(
Array.from(groupBy(e => e.pubkey, roomJoins).values()).map(events =>
first(sortEventsDesc(events)),
),
).filter(({pubkey, created_at}) => {
if (roomMembers.has(pubkey)) return false
if (
roomMembershipEvents.some(event => {
if (event.created_at <= created_at) {
return false
}
if (event.kind === ROOM_MEMBERS) {
return true
}
return getPubkeyTagValues(event.tags).includes(pubkey)
})
) {
return false
}
if (roomLeaves.some(e => e.pubkey === pubkey && e.created_at > created_at)) return false
return true
}),
)
}
return sortEventsDesc([...reports, ...pendingJoins])
},
)
+4 -22
View File
@@ -1,27 +1,20 @@
import {Room as LiveKitRoom} from "livekit-client" import {Room as LiveKitRoom} from "livekit-client"
import {derived, writable} from "svelte/store" import {derived, writable} from "svelte/store"
import type {Room} from "@app/groups" import {type Room} from "@app/core/state"
export type VoiceSession = { 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",
@@ -34,6 +27,8 @@ 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
@@ -46,19 +41,6 @@ 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) =>
+1 -1
View File
@@ -2,7 +2,7 @@ import {Track} from "livekit-client"
import {MediaQuery} from "svelte/reactivity" import {MediaQuery} from "svelte/reactivity"
import {derived, get, writable} from "svelte/store" import {derived, get, writable} from "svelte/store"
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores" import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
import {pushToast} from "@app/toast" import {pushToast} from "@app/util/toast"
export enum VideoCallLayout { export enum VideoCallLayout {
Chat = "chat", Chat = "chat",
+58 -240
View File
@@ -6,39 +6,34 @@ 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, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib" import {map, 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 {load} from "@welshman/net"
import {getLivekitEndpoint} from "$lib/livekit" import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util" import {AbortError, whenAborted, whenTimeout} from "$lib/util"
import { import {
currentVoiceRoom, currentVoiceRoom,
currentVoiceSession, currentVoiceSession,
voiceMicMuted,
participantFromLiveKitIdentity, participantFromLiveKitIdentity,
participantKey, participantKey,
participantMediaState, participantPubkeyMap,
pubkeyFromLiveKitIdentity,
speakingParticipants, speakingParticipants,
VoiceState, VoiceState,
type ParticipantMediaState,
type VoiceParticipant, type VoiceParticipant,
voiceState, voiceState,
} from "@app/call/stores" } from "@app/call/stores"
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video" import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
import {deriveLatestEventForUrl} from "@app/repository" import {deriveLatestEventForUrl, deriveRoom, makeRoomId} from "@app/core/state"
import {deriveRoom, makeRoomId} from "@app/groups" import {pushToast} from "@app/util/toast"
import {pushToast} from "@app/toast"
export const LIVEKIT_PARTICIPANTS = 39004 export const LIVEKIT_PARTICIPANTS = 39004
@@ -80,51 +75,20 @@ export const switchVoiceActiveDevice = async (
} }
} }
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({ const addParticipant = (identity: string) => {
muted: !participant.isMicrophoneEnabled, participantPubkeyMap.update(m => {
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(participant.identity, state) next.set(identity, pubkeyFromLiveKitIdentity(identity) ?? "")
return next return next
}) })
} }
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect. const deleteParticipant = (identity: string) => {
const resyncAfterReconnect = (room: LiveKitRoom) => { participantPubkeyMap.update(m => {
if (room !== activeRoom) return const next = new Map(m)
next.delete(identity)
const next = new Map<string, ParticipantMediaState>() return next
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 (
@@ -156,32 +120,26 @@ const fetchLivekitToken = async (
return response.json() return response.json()
} }
export const loadVoiceParticipants = (url: string, h: string) =>
load({
relays: [url],
filters: [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}],
})
export const deriveVoiceParticipants = (url: string, h: string) => 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(
[ [
participantMediaState, participantPubkeyMap,
currentVoiceRoom, currentVoiceRoom,
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]), deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
], ],
([$participantMediaState, $currentVoiceRoom, $publishedParticipantList]) => { ([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => {
const inCall = $participantMediaState.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h) const inCall = $participantPubkeyMap.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
if (inCall) { if (inCall) {
const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity) const participants = [...$participantPubkeyMap.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
if (!latestEvent) return [] if (!latestEvent) return []
const participants = removeUndefined( const participants = removeUndefined(
map( map(
(tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined), (tag: string[]) => (tag[1] ? {pubkey: tag[1], identity: tag[1]} : undefined),
getTags("participant", latestEvent.tags), getTags("participant", latestEvent.tags),
), ),
) )
@@ -194,8 +152,6 @@ 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
@@ -207,100 +163,28 @@ const setUpMicrophone = async (
capture = {deviceId: preferredMicId} capture = {deviceId: preferredMicId}
} }
try { try {
await Promise.race([ await participant.setMicrophoneEnabled(true, capture)
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 pushToast({theme: "error", message: "Could not access microphone"})
// 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"})
}
} }
return muted return muted
} }
// The room whose events are allowed to mutate shared state. Abandoned rooms const onRoomDisconnected = (reason?: DisconnectReason) => {
// (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)
if (reason === DisconnectReason.JOIN_FAILURE) { const message =
pushToast({theme: "error", message: "Could not connect to voice room. Please try again."}) reason === DisconnectReason.JOIN_FAILURE
} else if (get(currentVoiceRoom)) { ? "Could not connect to voice room. Please try again."
clearReconnectSchedule() : "Voice connection lost."
scheduleReconnect() pushToast({theme: "error", message})
} else {
pushToast({theme: "error", message: "Voice connection lost."})
}
} }
speakingParticipants.set([]) speakingParticipants.set([])
participantMediaState.set(new Map()) participantPubkeyMap.set(new Map())
} }
const onTrackSubscribed = (track: Track) => { const onTrackSubscribed = (track: Track) => {
@@ -330,8 +214,8 @@ const playJoinSound = () => {
audio.play().catch(() => {}) audio.play().catch(() => {})
} }
const onParticipantConnected = (participant: Participant) => { const onParticipantConnected = (participant: {identity: string}) => {
syncParticipantMedia(participant) addParticipant(participant.identity)
playJoinSound() playJoinSound()
} }
@@ -352,13 +236,8 @@ const onLocalTrackUnpublished = (
let joinAbortController: AbortController | undefined let joinAbortController: AbortController | undefined
const abortJoinVoiceRoom = () => {
joinAbortController?.abort()
}
export const cancelJoinVoiceRoom = () => { export const cancelJoinVoiceRoom = () => {
clearReconnectSchedule() joinAbortController?.abort()
abortJoinVoiceRoom()
} }
export const joinVoiceRoom = async ( export const joinVoiceRoom = async (
@@ -367,7 +246,10 @@ export const joinVoiceRoom = async (
startMuted = true, startMuted = true,
preferredMicId?: string, preferredMicId?: string,
): Promise<void> => { ): Promise<void> => {
abortJoinVoiceRoom() cancelJoinVoiceRoom()
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)
@@ -377,123 +259,62 @@ 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 {
// Tear down any existing session before joining. Bound it so a slow leave const {server_url, participant_token} = await fetchLivekitToken(url, h, signal)
// (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, makeOnRoomDisconnected(liveKitRoom)) liveKitRoom.on(RoomEvent.Disconnected, onRoomDisconnected)
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
} }
participantMediaState.set(new Map()) participantPubkeyMap.set(new Map())
syncParticipantMedia(liveKitRoom.localParticipant) addParticipant(liveKitRoom.localParticipant.identity)
for (const p of liveKitRoom.remoteParticipants.values()) { for (const p of liveKitRoom.remoteParticipants.values()) {
syncParticipantMedia(p) addParticipant(p.identity)
} }
// Bounded against timeout/abort inside setUpMicrophone: a stuck permission const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
// 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) { if (e instanceof AbortError) return
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
@@ -516,40 +337,37 @@ export const leaveVoiceRoom = async () => {
} }
} }
// Always tear down this room's connection and listeners. voiceState.set(VoiceState.Disconnected)
if (activeRoom === session.room) activeRoom = undefined videoPrimaryTileKey.set(undefined)
session.room.removeAllListeners() currentVoiceSession.set(undefined)
resetVideoCallLayout()
session.room.disconnect() session.room.disconnect()
speakingParticipants.set([])
participantPubkeyMap.set(new Map())
}
// Only reset shared UI state if this session is still current. A slow leave export const rejoinVoiceRoom = async (): Promise<void> => {
// that was superseded by a new join (bounded by a timeout in joinVoiceRoom) const target = get(currentVoiceRoom)
// must not clobber the freshly-joined session when it finally completes. if (!target) return
if (get(currentVoiceSession) === session) { return joinVoiceRoom(target.url, target.h)
voiceState.set(VoiceState.Disconnected)
videoPrimaryTileKey.set(undefined)
voiceMicMuted.set(true)
currentVoiceSession.set(undefined)
resetVideoCallLayout()
speakingParticipants.set([])
participantMediaState.set(new Map())
}
} }
export const toggleMute = async () => { export const toggleMute = async () => {
const session = get(currentVoiceSession) const session = get(currentVoiceSession)
if (!session) return if (!session) return
voiceMicMuted.update(not) const muted = !session.muted
if (get(voiceMicMuted)) { if (muted) {
// 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"})
} }
} }
-139
View File
@@ -1,139 +0,0 @@
import {DELETE, PROFILE, getPubkeyTagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {append, call, on, reject, remove, sort, sortBy, spec, uniq, uniqBy} from "@welshman/lib"
import type {Override} from "@welshman/lib"
import {createSearch, displayProfileByPubkey, pubkey, repository} from "@welshman/app"
import {derived, readable} from "svelte/store"
import {DM_KINDS} from "@app/content"
import type {RepositoryUpdate} from "@welshman/net"
import {makeDeriveItem, throttled} from "@welshman/store"
export type Chat = {
id: string
pubkeys: string[]
messages: TrustedEvent[]
last_activity: number
search_text: string
}
export const getChatPubkeys = (pubkeys: string[]) => sort(uniq(append(pubkey.get()!, pubkeys)))
export const getChatPubkeysFromEvent = (event: TrustedEvent) =>
getChatPubkeys(getPubkeyTagValues(event.tags).concat(event.pubkey))
export const makeChatId = (pubkeys: string[]) => {
const userPubkey = pubkey.get()!
const otherPubkeys = remove(userPubkey, uniq(pubkeys))
const visiblePubkeys = otherPubkeys.length === 0 ? [userPubkey] : otherPubkeys
return sort(visiblePubkeys).join(",")
}
export const splitChatId = (id: string) => getChatPubkeys(id.split(","))
export const chatsById = call(() => {
const chatsById = new Map<string, Chat>()
const chatsByPubkey = new Map<string, string[]>()
const addSearchText = (chat: Override<Chat, {search_text?: string}>) => {
chat.search_text =
chat.pubkeys.length === 1
? displayProfileByPubkey(chat.pubkeys[0]) + " note to self"
: remove(pubkey.get()!, chat.pubkeys).map(displayProfileByPubkey).join(" ")
return chat as Chat
}
return readable(chatsById, set => {
const indexChatByPubkeys = (chat: Chat) => {
for (const pubkey of chat.pubkeys) {
chatsByPubkey.set(pubkey, uniq(append(chat.id, chatsByPubkey.get(pubkey) || [])))
}
}
const addEvents = (events: TrustedEvent[]) => {
let dirty = false
for (const event of events) {
if (DM_KINDS.includes(event.kind)) {
const pubkeys = getChatPubkeysFromEvent(event)
const id = makeChatId(pubkeys)
const chat = chatsById.get(id)
const messages = sortBy(
e => -e.created_at,
uniqBy(e => e.id, append(event, chat?.messages || [])),
)
const last_activity = Math.max(chat?.last_activity || 0, event.created_at)
const updatedChat = addSearchText({id, pubkeys, messages, last_activity})
chatsById.set(id, updatedChat)
indexChatByPubkeys(updatedChat)
dirty = true
}
if (event.kind === PROFILE) {
for (const chatId of chatsByPubkey.get(event.pubkey) || []) {
const chat = chatsById.get(chatId)
if (chat) {
addSearchText(chat)
dirty = true
}
}
}
}
if (dirty) {
set(chatsById)
}
}
const removeEvents = (removed: Set<string>) => {
let dirty = false
for (const id of removed) {
const event = repository.getEvent(id)
if (event && DM_KINDS.includes(event.kind)) {
for (const chatId of chatsByPubkey.get(event.pubkey) || []) {
const chat = chatsById.get(chatId)
if (chat) {
chat.messages = reject(spec({id: event.id}), chat.messages)
dirty = true
}
}
}
}
if (dirty) {
set(chatsById)
}
}
addEvents(repository.query([{kinds: [...DM_KINDS, DELETE, PROFILE]}]))
const unsubscribers = [
on(repository, "update", ({added, removed}: RepositoryUpdate) => {
// Do this async so that profiles are populated
setTimeout(() => {
addEvents(added)
removeEvents(removed)
}, 200)
}),
]
return () => unsubscribers.forEach(call)
})
})
export const deriveChat = makeDeriveItem(chatsById)
export const chatSearch = derived(throttled(1500, chatsById), $chatsByPubkey => {
return createSearch(
sortBy(c => -c.last_activity, Array.from($chatsByPubkey.values())),
{
getValue: (chat: Chat) => chat.id,
fuseOptions: {keys: ["search_text"]},
},
)
})
-16
View File
@@ -1,16 +0,0 @@
import type {TrustedEvent} from "@welshman/util"
import {COMMENT, makeEvent} from "@welshman/util"
import {publishThunk, tagEventForComment} from "@welshman/app"
export type CommentParams = {
event: TrustedEvent
content: string
tags?: string[][]
url?: string
}
export const makeComment = ({url, event, content, tags = []}: CommentParams) =>
makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event, url)]})
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
publishThunk({event: makeComment({url: relays[0], ...params}), relays})
+1 -1
View File
@@ -5,7 +5,7 @@
import Landing from "@app/components/Landing.svelte" import Landing from "@app/components/Landing.svelte"
import Toast from "@app/components/Toast.svelte" import Toast from "@app/components/Toast.svelte"
import PrimaryNav from "@app/components/PrimaryNav.svelte" import PrimaryNav from "@app/components/PrimaryNav.svelte"
import {modal} from "@app/modal" import {modal} from "@app/util/modal"
interface Props { interface Props {
children: Snippet children: Snippet
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import QRCode from "@app/components/QRCode.svelte" import QRCode from "@app/components/QRCode.svelte"
import type {Nip46Controller} from "@app/nip46" import type {Nip46Controller} from "@app/util/nip46"
type Props = { type Props = {
controller: Nip46Controller controller: Nip46Controller
+2 -2
View File
@@ -7,8 +7,8 @@
import QrCode from "@assets/icons/qr-code.svg?dataurl" import QrCode from "@assets/icons/qr-code.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import InfoBunker from "@app/components/InfoBunker.svelte" import InfoBunker from "@app/components/InfoBunker.svelte"
import type {Nip46Controller} from "@app/nip46" import type {Nip46Controller} from "@app/util/nip46"
import {pushModal} from "@app/modal" import {pushModal} from "@app/util/modal"
type Props = { type Props = {
controller: Nip46Controller controller: Nip46Controller
@@ -12,11 +12,9 @@
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte" import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
import {publishDelete} from "@app/deletes" import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {publishReaction} from "@app/reactions" import {makeCalendarPath, makeSpacePath} from "@app/util/routes"
import {canEnforceNip70} from "@app/relays" import {pushModal} from "@app/util/modal"
import {makeCalendarPath, makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal"
type Props = { type Props = {
url: string url: string
+4 -4
View File
@@ -18,11 +18,11 @@
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte" import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED, publishRoomQuote} from "@app/groups" import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/drafts" import {DraftKey} from "@app/util/drafts"
import {pushToast} from "@app/toast" import {pushToast} from "@app/util/toast"
import {canEnforceNip70} from "@app/relays" import {canEnforceNip70, publishRoomQuote} from "@app/core/commands"
type Values = { type Values = {
d: string d: string
@@ -19,17 +19,15 @@
const end = $derived(parseInt(meta.end)) const end = $derived(parseInt(meta.end))
</script> </script>
<div class="flex flex-col justify-between gap-1"> <div class="flex grow flex-wrap justify-between gap-2">
<p class="text-lg">{meta.title || meta.name}</p> <p class="text-xl">{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 flex-wrap gap-2 text-xs"> <div class="flex items-center gap-2 text-sm">
<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)}
+1 -1
View File
@@ -6,7 +6,7 @@
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte" import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import RoomLink from "@app/components/RoomLink.svelte" import RoomLink from "@app/components/RoomLink.svelte"
import {makeCalendarPath} from "@app/routes" import {makeCalendarPath} from "@app/util/routes"
type Props = { type Props = {
url: string url: string
+8 -11
View File
@@ -53,13 +53,11 @@
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} from "@app/settings" import {userSettingsValues, deriveChat} from "@app/core/state"
import {deriveChat, makeChatId} from "@app/chats" import {pushModal} from "@app/util/modal"
import {pushModal} from "@app/modal" import {DraftKey} from "@app/util/drafts"
import {DraftKey} from "@app/drafts" import {makeDelete, prependParent} from "@app/core/commands"
import {makeDelete} from "@app/deletes" import {pushToast} from "@app/util/toast"
import {prependParent} from "@app/groups"
import {pushToast} from "@app/toast"
type Props = { type Props = {
pubkeys: string[] pubkeys: string[]
@@ -68,9 +66,8 @@
const {pubkeys, info}: Props = $props() const {pubkeys, info}: Props = $props()
const chatId = makeChatId(pubkeys) const chat = deriveChat(pubkeys)
const chat = deriveChat(chatId) const draftKey = new DraftKey<{content?: string | object}>(`dm:${$chat?.id}`)
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)))
@@ -282,7 +279,7 @@
</div> </div>
</PageBar> </PageBar>
<PageContent class="flex flex-col-reverse gap-2 py-2 !mb-0"> <PageContent class="flex flex-col-reverse gap-2 py-4">
{#if missingRelayLists.length > 0} {#if missingRelayLists.length > 0}
<div class="py-12"> <div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center"> <div class="card2 col-2 m-auto max-w-md items-center text-center">
+1 -1
View File
@@ -10,7 +10,7 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {type DraftKey} from "@app/drafts" import {type DraftKey} from "@app/util/drafts"
type Values = { type Values = {
content?: string | object content?: string | object
+2 -2
View File
@@ -12,8 +12,8 @@
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/env" import {DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/core/state"
import {pushToast} from "@app/toast" import {pushToast} from "@app/util/toast"
type Props = { type Props = {
next: () => void next: () => void

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