Compare commits

...

9 Commits

Author SHA1 Message Date
userAdityaa 631dbed375 fix: mobile room chat header layout 2026-05-22 03:00:34 +05:30
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
Docker / build-and-push-image (push) Successful in 11m19s
2026-05-12 08:46:50 -07:00
Jon Staab 0998639d59 Push to gitea package registry
Docker / build-and-push-image (push) Successful in 11m31s
2026-05-11 13:49:37 -07:00
Jon Staab eccde07d06 Fix dockerfile again
Docker / build-and-push-image (push) Successful in 12m2s
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
Docker / build-and-push-image (push) Successful in 12m9s
2026-05-11 12:28:42 -07:00
16 changed files with 110 additions and 73 deletions
-1
View File
@@ -4,7 +4,6 @@ ios
build
# Git
.git
.gitignore
# Env files (keep .env for build; exclude local overrides)
+4 -4
View File
@@ -5,8 +5,8 @@ on:
branches: [master]
env:
REGISTRY: ghcr.io
IMAGE_NAME: coracle-social/flotilla
REGISTRY: gitea.coracle.social
IMAGE_NAME: coracle/flotilla
jobs:
build-and-push-image:
@@ -23,8 +23,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
username: hodlbod
password: ${{ secrets.PACKAGE_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
+13 -26
View File
@@ -1,40 +1,27 @@
# Stage 1: Build
# 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 .
# Build and run the Flotilla web server.
#
# 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
FROM node:22-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends curl
RUN npm install -g pnpm@latest
RUN npm install -g pnpm@10.33.0
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}
RUN pnpm i --frozen-lockfile
ENV NODE_OPTIONS=--max_old_space_size=16384
COPY . .
RUN pnpm run build
FROM node:20-alpine
WORKDIR /app
# Install production dependencies needed by the Node server runtime
RUN npm install -g pnpm@10.33.0
COPY package.json pnpm-lock.yaml ./
RUN pnpm i --prod --frozen-lockfile --ignore-scripts
# Copy only the built output and server source - no app source, no .env, no dev deps
COPY --from=builder /app/build ./build
COPY --from=builder /app/server.js ./server.js
EXPOSE 3000
CMD ["node", "server.js"]
+2 -2
View File
@@ -37,12 +37,12 @@ pnpm run start
Or, if you prefer to use a container:
```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:
```sh
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'
```
+8
View File
@@ -247,6 +247,14 @@ app.use(
: "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)}"`)
}
},
}),
)
+8
View File
@@ -423,10 +423,18 @@ progress[value]::-webkit-progress-value {
/* Keyboard open state adjustments */
body.keyboard-open {
--saib: 0px;
}
body.keyboard-open .hide-on-keyboard {
display: none;
}
body.keyboard-open .chat__compose {
margin-bottom: 0;
}
/* chat view */
.chat__compose {
+3 -1
View File
@@ -6,11 +6,13 @@ export type VoiceSession = {
url: string
h: string
room: LiveKitRoom
muted: boolean
cameraOn: 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 VoiceParticipant = {pubkey?: Pubkey; identity: string}
+8 -6
View File
@@ -13,7 +13,7 @@ import {
type AudioCaptureOptions,
} from "livekit-client"
import {derived, get} from "svelte/store"
import {map, removeUndefined, uniqBy} from "@welshman/lib"
import {map, not, removeUndefined, uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
import {signer} from "@welshman/app"
@@ -22,6 +22,7 @@ import {AbortError, whenAborted, whenTimeout} from "$lib/util"
import {
currentVoiceRoom,
currentVoiceSession,
voiceMicMuted,
participantFromLiveKitIdentity,
participantKey,
participantPubkeyMap,
@@ -173,6 +174,7 @@ const setUpMicrophone = async (
const onRoomDisconnected = (reason?: DisconnectReason) => {
videoPrimaryTileKey.set(undefined)
voiceMicMuted.set(true)
currentVoiceSession.set(undefined)
resetVideoCallLayout()
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
@@ -295,11 +297,11 @@ export const joinVoiceRoom = async (
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
voiceMicMuted.set(muted)
currentVoiceSession.set({
url,
h,
room: liveKitRoom,
muted,
cameraOn: false,
screenShareOn: false,
})
@@ -339,6 +341,7 @@ export const leaveVoiceRoom = async () => {
voiceState.set(VoiceState.Disconnected)
videoPrimaryTileKey.set(undefined)
voiceMicMuted.set(true)
currentVoiceSession.set(undefined)
resetVideoCallLayout()
session.room.disconnect()
@@ -356,18 +359,17 @@ export const toggleMute = async () => {
const session = get(currentVoiceSession)
if (!session) return
const muted = !session.muted
if (muted) {
voiceMicMuted.update(not)
if (get(voiceMicMuted)) {
// Disable and re-enable microphone to trigger permission prompt
session.room.localParticipant.setMicrophoneEnabled(false)
currentVoiceSession.set({...session, muted})
return
}
try {
await session.room.localParticipant.setMicrophoneEnabled(true)
currentVoiceSession.set({...session, muted})
} catch (e) {
voiceMicMuted.set(true)
pushToast({theme: "error", message: "Could not access microphone"})
}
}
+3 -7
View File
@@ -11,11 +11,7 @@
const {url, h, ...props}: Props = $props()
</script>
<div class="flex grow items-center justify-between gap-4 {props.class}">
<div class="flex items-center gap-2">
<RoomImage {url} {h} />
<div class="min-w-0 overflow-hidden text-ellipsis">
<RoomName {url} {h} />
</div>
</div>
<div class="flex min-w-0 items-center gap-2 {props.class}">
<RoomImage {url} {h} />
<RoomName {url} {h} />
</div>
+8 -8
View File
@@ -23,20 +23,20 @@
</script>
<PageBar {...props}>
<div class="flex">
<Button onclick={back} class="place-self-start pr-3 md:hidden">
<Icon icon={ArrowLeft} size={7} />
<div class="flex min-w-0 flex-1 items-center gap-1">
<Button onclick={back} class="btn btn-ghost btn-square shrink-0 md:hidden">
<Icon icon={ArrowLeft} size={6} />
</Button>
<div class="ellipsize whitespace-nowrap flex grow items-center justify-between gap-4">
<div class="flex flex-col">
<div class="flex gap-2 items-center">
<div class="flex min-w-0 flex-1 items-center justify-between gap-2">
<div class="flex min-w-0 flex-col gap-0.5">
<div class="flex min-w-0 items-center gap-2">
{@render title?.()}
</div>
<div class="text-xs text-primary md:hidden">
<div class="truncate text-xs leading-4 text-primary md:hidden">
{displayRelayUrl(url)}
</div>
</div>
<div class="flex gap-2 items-start">
<div class="flex shrink-0 items-center gap-1">
{@render action?.()}
</div>
</div>
+4 -2
View File
@@ -144,6 +144,9 @@
const useSpotlightLayout = $derived(primaryTile !== undefined)
const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 2)
const multiGridClass = $derived(
layout === VideoCallLayout.Split ? "grid-cols-1" : "grid-cols-1 sm:grid-cols-2",
)
$effect(() => {
const k = $videoPrimaryTileKey
@@ -238,8 +241,7 @@
{/if}
</div>
{:else if useMultiGrid}
<div
class="grid min-h-0 flex-1 grid-cols-1 content-start gap-2 overflow-y-auto sm:grid-cols-2">
<div class={cx("grid min-h-0 flex-1 content-start gap-2 overflow-y-auto", multiGridClass)}>
{#each videoTiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
+5 -5
View File
@@ -41,6 +41,7 @@
VoiceState,
currentVoiceSession,
currentVoiceRoom,
voiceMicMuted,
voiceState,
isLocalSpeaking,
} from "@app/call/stores"
@@ -183,18 +184,17 @@
</Button>
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
<Button
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
data-tip={$voiceMicMuted ? "Unmute" : "Mute"}
class={cx(
mediaToggleClass,
"overflow-visible",
!$currentVoiceSession.muted && $isLocalSpeaking && "text-primary",
$currentVoiceSession.muted &&
"text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
!$voiceMicMuted && $isLocalSpeaking && "text-primary",
$voiceMicMuted && "text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
)}
onclick={toggleMute}>
<span class="relative inline-flex items-center justify-center overflow-visible">
<Icon icon={Microphone} size={4} />
{#if $currentVoiceSession.muted}
{#if $voiceMicMuted}
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
aria-hidden="true">
+18 -4
View File
@@ -1,4 +1,4 @@
import {derived} from "svelte/store"
import {derived, get, writable} from "svelte/store"
import {Badge} from "@capawesome/capacitor-badge"
import {synced, throttled, withGetter} from "@welshman/store"
import {pubkey, tracker, repository, relaysByUrl} from "@welshman/app"
@@ -36,6 +36,9 @@ export const deriveChecked = (key: string) => derived(checked, prop<number>(key)
export const setChecked = (key: string) => checked.update(assoc(key, now()))
/** Room path while video call UI hides chat; checked + badge stay active until chat is shown. */
export const deferredRoomPath = writable<string | undefined>(undefined)
export const syncChecked = () => {
let prev = ""
@@ -57,8 +60,11 @@ export const syncChecked = () => {
// Set checked when we visit a given page - but delay it a tad
setTimeout(() => {
const defer = get(deferredRoomPath)
checked.update($checked => {
for (const path of getPaths($page.url.pathname)) {
if (defer && path === defer) continue
$checked[path] = now()
}
@@ -151,9 +157,17 @@ export const allNotifications = derived(
},
)
export const notifications = derived([page, allNotifications], ([$page, $allNotifications]) => {
return new Set([...$allNotifications].filter(p => !$page.url.pathname.startsWith(p)))
})
export const notifications = derived(
[page, allNotifications, deferredRoomPath],
([$page, $allNotifications, $deferredRoomPath]) =>
new Set(
[...$allNotifications].filter(p => {
if (!$page.url.pathname.startsWith(p)) return true
if ($deferredRoomPath && p === $deferredRoomPath) return true
return false
}),
),
)
// Badges
+5 -2
View File
@@ -9,8 +9,11 @@
const {children, ...props}: Props = $props()
</script>
<div data-component="PageBar" class="relative z-nav p-2 -mb-4 {props.class}">
<div class="rounded-xl bg-base-100 p-4 shadow-md h-20 md:h-12 flex flex-col justify-center">
<div
data-component="PageBar"
class="relative z-nav -mx-sai -mt-sai shrink-0 md:mx-0 md:-mb-4 md:mt-0 md:p-2 {props.class}">
<div
class="border-base-300 bg-base-100 flex min-h-[calc(4rem+var(--sait))] items-center border-b px-4 pb-2 pt-sai md:h-12 md:min-h-0 md:rounded-xl md:border-0 md:p-4 md:shadow-md">
{@render children?.()}
</div>
</div>
+20 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import {onMount} from "svelte"
import {onDestroy, onMount} from "svelte"
import {readable} from "svelte/store"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
@@ -24,9 +24,9 @@
import SpaceBar from "@app/components/SpaceBar.svelte"
import RoomCompose from "@app/components/RoomCompose.svelte"
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
import RoomImage from "@app/components/RoomImage.svelte"
import RoomDetail from "@app/components/RoomDetail.svelte"
import RoomItem from "@app/components/RoomItem.svelte"
import RoomImage from "@app/components/RoomImage.svelte"
import RoomName from "@app/components/RoomName.svelte"
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
@@ -49,7 +49,8 @@
import {VideoCallLayout, videoCallLayout, videoTileCount} from "@app/call/video"
import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit"
import {checked} from "@app/util/notifications"
import {checked, deferredRoomPath, setChecked} from "@app/util/notifications"
import {makeRoomPath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
@@ -77,6 +78,21 @@
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
)
const roomPath = makeRoomPath(url, h)
const videoCallChatHidden = $derived(
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
)
$effect(() => {
deferredRoomPath.set(videoCallChatHidden ? roomPath : undefined)
if (voiceConnectedHere && !videoCallChatHidden) {
setChecked(roomPath)
}
})
onDestroy(() => deferredRoomPath.set(undefined))
let prevVideoTileCount = $state(0)
$effect(() => {
@@ -452,7 +468,7 @@
bind:element
onscroll={onScroll}
class={cx(
"flex-col-reverse pb-0! pt-4",
"flex-col-reverse pb-0! pt-2 md:pt-4",
showMobileVideoPanel ? "hidden md:flex md:flex-col-reverse" : "flex",
pageContentHiddenDesktopVideoOnly && "md:hidden",
)}>
+1 -1
View File
@@ -316,7 +316,7 @@
{/snippet}
</SpaceBar>
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4 pb-0!">
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-2 pb-0! md:pt-4">
{#if loadingForward}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>