diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml new file mode 100644 index 0000000..629279a --- /dev/null +++ b/.github/workflows/auto-tag.yml @@ -0,0 +1,45 @@ +name: Auto Tag Desktop Release + +on: + push: + branches: + - main + paths: + - "apps/desktop/src-tauri/tauri.conf.json" + +permissions: + contents: write + +jobs: + auto-tag: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get version from tauri.conf.json + id: version + run: | + VERSION=$(jq -r '.version' apps/desktop/src-tauri/tauri.conf.json) + if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then + echo "::error::No version found in tauri.conf.json" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check if tag already exists + id: check + run: | + if git rev-parse "v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create and push tag + if: steps.check.outputs.exists == 'false' + run: | + git tag "${{ steps.version.outputs.tag }}" + git push origin "${{ steps.version.outputs.tag }}" diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 3ff29ed..52528fd 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -77,7 +77,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app" -version = "0.1.0" +version = "0.1.1" dependencies = [ "log", "serde", @@ -86,6 +86,7 @@ dependencies = [ "tauri-build", "tauri-plugin-log", "tauri-plugin-notification", + "tauri-plugin-process", "tauri-plugin-updater", ] @@ -4316,6 +4317,16 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.0" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 7471480..5b58696 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "0.1.0" +version = "0.1.1" description = "A Tauri App" authors = ["you"] license = "" @@ -24,4 +24,5 @@ log = "0.4" tauri = { version = "2.10.3", features = [] } tauri-plugin-log = "2" tauri-plugin-notification = "2" +tauri-plugin-process = "2" tauri-plugin-updater = "2" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index b72a4ac..23ec85b 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -3,5 +3,10 @@ "identifier": "default", "description": "enables the default permissions", "windows": ["main"], - "permissions": ["core:default", "notification:default", "updater:default"] + "permissions": [ + "core:default", + "notification:default", + "process:default", + "updater:default" + ] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1ace27c..5aa1301 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .setup(|app| { if cfg!(debug_assertions) { diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 454e0e2..48d3a26 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Townhall", - "version": "0.1.0", + "version": "0.1.1", "identifier": "com.townhall.desktop", "build": { "frontendDist": "https://app.townhall.chat", diff --git a/apps/web/package.json b/apps/web/package.json index 1f9871e..481c8e8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,8 @@ "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.120.3", "@tauri-apps/plugin-notification": "^2.3.3", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "@tiptap/extension-link": "^3.20.0", "@tiptap/extension-mention": "^3.20.0", "@tiptap/markdown": "^3.20.0", diff --git a/apps/web/src/components/guild/guild-settings-dialog.tsx b/apps/web/src/components/guild/guild-settings-dialog.tsx index f997f69..b3c720a 100644 --- a/apps/web/src/components/guild/guild-settings-dialog.tsx +++ b/apps/web/src/components/guild/guild-settings-dialog.tsx @@ -1,4 +1,3 @@ -import { authClient } from "@repo/auth/client" import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/avatar" import { Button } from "@repo/ui/components/button" import { diff --git a/apps/web/src/components/onboarding/onboarding-dialog.tsx b/apps/web/src/components/onboarding/onboarding-dialog.tsx index 287e2d3..da66dad 100644 --- a/apps/web/src/components/onboarding/onboarding-dialog.tsx +++ b/apps/web/src/components/onboarding/onboarding-dialog.tsx @@ -1,7 +1,9 @@ "use client" import { authClient } from "@repo/auth/client" +import { env } from "@repo/env/client" import { Button } from "@repo/ui/components/button" +import { Checkbox } from "@repo/ui/components/checkbox" import { Dialog, DialogContent, @@ -24,6 +26,9 @@ type Step = "username" | "welcome" | "create" | "join" const MIN_USERNAME_LENGTH = 3 const MAX_USERNAME_LENGTH = 30 const USERNAME_REGEX = /^[a-zA-Z0-9_.]+$/ +// TODO: Remove hardcoded invite code once we have a proper discovery/featured guilds system +const TOWNHALL_INVITE_CODE = "k9yDieWZ" +const showTownhallJoin = !env.NEXT_PUBLIC_SELF_HOSTED function normalizeSlugInput(value: string) { return value @@ -64,11 +69,20 @@ export function OnboardingDialog({ open }: { open: boolean }) { const [slug, setSlug] = useState("") const [slugEdited, setSlugEdited] = useState(false) const [inviteLink, setInviteLink] = useState("") + const [joinTownhall, setJoinTownhall] = useState(showTownhallJoin) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const queryClient = useQueryClient() const navigate = useNavigate() + const acceptTownhallInvite = useCallback(() => { + if (!joinTownhall) return + apiClient.v1.invites[":code"].accept + .$post({ param: { code: TOWNHALL_INVITE_CODE } }) + .then(() => queryClient.invalidateQueries({ queryKey: ["guilds"] })) + .catch((err) => console.error("Failed to join Townhall guild:", err)) + }, [joinTownhall, queryClient]) + // Username step state const [username, setUsername] = useState("") const [usernameAvailability, setUsernameAvailability] = useState< @@ -184,6 +198,8 @@ export function OnboardingDialog({ open }: { open: boolean }) { const createdGuildSlug = res.data?.slug ?? normalizedSlug await queryClient.invalidateQueries({ queryKey: ["guilds"] }) + acceptTownhallInvite() + let firstChannelId: string | null = null try { firstChannelId = await getFirstChannelId(createdGuildSlug) @@ -224,6 +240,10 @@ export function OnboardingDialog({ open }: { open: boolean }) { return } + if (inviteCode !== TOWNHALL_INVITE_CODE) { + acceptTownhallInvite() + } + await navigate({ to: "/invite/$code", params: { code: inviteCode }, @@ -375,6 +395,34 @@ export function OnboardingDialog({ open }: { open: boolean }) { + + {showTownhallJoin && ( + // biome-ignore lint/a11y/useKeyWithClickEvents: Label + Checkbox handle keyboard a11y +
setJoinTownhall((prev) => !prev)} + className="mt-4 flex w-full cursor-pointer items-start gap-3 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-accent" + > + + setJoinTownhall(checked === true) + } + onClick={(e) => e.stopPropagation()} + className="mt-0.5" + /> + +
+ )} )} diff --git a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx index 377d8f3..caa919f 100644 --- a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx +++ b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx @@ -1,11 +1,11 @@ import { - closestCenter, DndContext, type DragEndEvent, type DragOverEvent, DragOverlay, type DragStartEvent, PointerSensor, + rectIntersection, useSensor, useSensors, } from "@dnd-kit/core" @@ -31,6 +31,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useNavigate, useParams } from "@tanstack/react-router" import { ChevronDown, + FolderPlus, Hash, Megaphone, MessageSquare, @@ -172,6 +173,9 @@ export function ChannelList() { const [createDialogOpen, setCreateDialogOpen] = useState(false) const [createParentId, setCreateParentId] = useState(null) + const [createForceType, setCreateForceType] = useState< + "category" | undefined + >(undefined) const [activeItem, setActiveItem] = useState<{ channel: Channel @@ -219,7 +223,28 @@ export function ChannelList() { const overItem = findChannel(over.id as string) if (!activeItem || !overItem) return - // Don't allow dragging categories into other categories + // Category-to-category: reorder optimistically + if (activeItem.isCategory && overItem.isCategory) { + queryClient.setQueryData( + ["channels", guildSlug], + (old: ChannelData | undefined) => { + if (!old) return old + const newData = structuredClone(old) + const oldIdx = newData.categories.findIndex( + (c) => c.id === active.id + ) + const newIdx = newData.categories.findIndex((c) => c.id === over.id) + if (oldIdx >= 0 && newIdx >= 0 && oldIdx !== newIdx) { + const moved = newData.categories.splice(oldIdx, 1)[0] + if (moved) newData.categories.splice(newIdx, 0, moved) + } + return newData + } + ) + return + } + + // Don't allow dragging categories onto channels if (activeItem.isCategory) return // Find which container the active item is in @@ -308,17 +333,7 @@ export function ChannelList() { if (!old) return old const updated = structuredClone(old) - if (draggedItem.isCategory && overItem.isCategory) { - // Reorder categories - const oldIdx = updated.categories.findIndex( - (c) => c.id === active.id - ) - const newIdx = updated.categories.findIndex((c) => c.id === over.id) - if (oldIdx >= 0 && newIdx >= 0) { - const moved = updated.categories.splice(oldIdx, 1)[0] - if (moved) updated.categories.splice(newIdx, 0, moved) - } - } else if (!draggedItem.isCategory) { + if (!draggedItem.isCategory) { // Reorder within same container const container = draggedItem.channel.parentId if (container === null) { @@ -381,7 +396,7 @@ export function ChannelList() { <> Channels - +
+ + +
)} {/* Uncategorized channels */} @@ -449,6 +480,7 @@ export function ChannelList() { channels={cat.channels} draggingCategory={activeItem?.isCategory ?? false} activeChannelId={activeChannelId} + canCreate={canCreate} canManage={canManage} canDelete={canDelete} onChannelClick={(channelId) => { @@ -458,6 +490,11 @@ export function ChannelList() { }) closeMobileSidebar(false) }} + onCreateChannel={(parentId) => { + setCreateParentId(parentId) + setCreateForceType(undefined) + setCreateDialogOpen(true) + }} /> ))} @@ -485,8 +522,12 @@ export function ChannelList() {
{ + setCreateDialogOpen(open) + if (!open) setCreateForceType(undefined) + }} parentId={createParentId} + forceType={createForceType} /> ) @@ -498,20 +539,27 @@ function SortableCategorySection({ channels, draggingCategory, activeChannelId, + canCreate, canManage, canDelete, onChannelClick, + onCreateChannel, }: { id: string name: string channels: Channel[] draggingCategory: boolean activeChannelId?: string + canCreate: boolean canManage: boolean canDelete: boolean onChannelClick?: (channelId: string) => void + onCreateChannel?: (parentId: string) => void }) { const [collapsed, setCollapsed] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) + const [editOpen, setEditOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) const { attributes, listeners, @@ -527,22 +575,88 @@ function SortableCategorySection({ opacity: isDragging ? 0.5 : 1, } + const categoryAsChannel = { + id, + name, + type: "category" as const, + } as Channel + return (
- + + + + {name} + +
+ {canCreate && ( + + )} + {canManage && ( + + + + + + { + setMenuOpen(false) + setEditOpen(true) + }} + > + Edit Category + + {canDelete && ( + <> + + { + setMenuOpen(false) + setDeleteOpen(true) + }} + className="text-destructive focus:text-destructive" + > + Delete Category + + + )} + + + )} +
+
+ + {!collapsed && ( void parentId?: string | null + forceType?: "category" }) { const { guildSlug } = useParams({ strict: false }) const navigate = useNavigate() @@ -47,16 +49,19 @@ export function CreateChannelDialog({ e.preventDefault() const trimmed = name.trim() if (!trimmed || !guildSlug) return + if (forceType !== "category" && !normalizedName) return setError(null) setLoading(true) + const isCategory = forceType === "category" + try { const res = await apiClient.v1.guilds[":guildSlug"].channels.$post({ param: { guildSlug }, json: { - name: trimmed.toLowerCase().replace(/\s+/g, "-"), - type, - ...(parentId ? { parentId } : {}), + name: isCategory ? trimmed : normalizedName, + type: isCategory ? "category" : type, + ...(parentId && !isCategory ? { parentId } : {}), }, }) @@ -64,7 +69,7 @@ export function CreateChannelDialog({ const data = await res.json().catch(() => null) setError( (data as { message?: string } | null)?.message ?? - "Failed to create channel" + `Failed to create ${isCategory ? "category" : "channel"}` ) return } @@ -78,10 +83,12 @@ export function CreateChannelDialog({ setType("text") setError(null) - navigate({ - to: "/$guildSlug/$channelId", - params: { guildSlug, channelId: channel.id }, - }) + if (!isCategory) { + navigate({ + to: "/$guildSlug/$channelId", + params: { guildSlug, channelId: channel.id }, + }) + } } catch { setError("Something went wrong. Please try again.") } finally { @@ -98,6 +105,8 @@ export function CreateChannelDialog({ onOpenChange(open) } + const isCategory = forceType === "category" + const normalizedName = name .trim() .toLowerCase() @@ -108,58 +117,68 @@ export function CreateChannelDialog({ - Create Channel + + {isCategory ? "Create Category" : "Create Channel"} + - Add a new channel to your guild. + {isCategory + ? "Add a new category to organize your channels." + : "Add a new channel to your guild."}
-
- - -
+ {!isCategory && ( +
+ + +
+ )}
setName(e.target.value)} disabled={loading} autoFocus /> - {normalizedName && normalizedName !== name.trim() && ( -

- Will be created as{" "} - #{normalizedName} -

- )} + {!isCategory && + normalizedName && + normalizedName !== name.trim() && ( +

+ Will be created as{" "} + #{normalizedName} +

+ )}
{error &&

{error}

}
diff --git a/apps/web/src/components/sidebar/channel-panel/delete-channel-dialog.tsx b/apps/web/src/components/sidebar/channel-panel/delete-channel-dialog.tsx index 3932708..e572ebc 100644 --- a/apps/web/src/components/sidebar/channel-panel/delete-channel-dialog.tsx +++ b/apps/web/src/components/sidebar/channel-panel/delete-channel-dialog.tsx @@ -54,16 +54,31 @@ export function DeleteChannelDialog({ }, }) + const isCategory = channel.type === "category" + return ( - Delete Channel + + {isCategory ? "Delete Category" : "Delete Channel"} + - Are you sure you want to delete{" "} - #{channel.name}? This will - permanently delete all messages in this channel. This action cannot - be undone. + {isCategory ? ( + <> + Are you sure you want to delete the{" "} + {channel.name} category? + Channels inside it will become uncategorized. This action cannot + be undone. + + ) : ( + <> + Are you sure you want to delete{" "} + #{channel.name}? This + will permanently delete all messages in this channel. This + action cannot be undone. + + )} @@ -73,7 +88,7 @@ export function DeleteChannelDialog({ loading={deleteMutation.isPending} variant="destructive" > - Delete Channel + {isCategory ? "Delete Category" : "Delete Channel"} diff --git a/apps/web/src/components/sidebar/channel-panel/edit-channel-dialog.tsx b/apps/web/src/components/sidebar/channel-panel/edit-channel-dialog.tsx index 02c849f..6be4523 100644 --- a/apps/web/src/components/sidebar/channel-panel/edit-channel-dialog.tsx +++ b/apps/web/src/components/sidebar/channel-panel/edit-channel-dialog.tsx @@ -50,10 +50,14 @@ export function EditChannelDialog({ ":channelId" ].$patch({ param: { guildSlug: validatedGuildSlug, channelId: channel.id }, - json: { name, topic: topic || undefined }, + json: { + name, + ...(channel.type !== "category" ? { topic: topic || undefined } : {}), + }, }) if (!res.ok) { - let message = "Failed to update channel" + const label = channel.type === "category" ? "category" : "channel" + let message = `Failed to update ${label}` const responseText = await res.text() if (responseText) { @@ -83,23 +87,31 @@ export function EditChannelDialog({ onOpenChange(false) }, onError: (error) => { + const label = channel.type === "category" ? "category" : "channel" toast.error( - error instanceof Error ? error.message : "Failed to update channel" + error instanceof Error ? error.message : `Failed to update ${label}` ) }, }) const hasChanges = - name !== (channel.name ?? "") || topic !== (channel.topic ?? "") + name !== (channel.name ?? "") || + (channel.type !== "category" && topic !== (channel.topic ?? "")) const isValid = name.trim().length > 0 + const isCategory = channel.type === "category" + return ( - Edit Channel + + {isCategory ? "Edit Category" : "Edit Channel"} + - Update the channel name and topic. + {isCategory + ? "Update the category name." + : "Update the channel name and topic."}
-
- -