diff --git a/src-tauri/src/commands/community.rs b/src-tauri/src/commands/community.rs index 744f43e..a432b66 100644 --- a/src-tauri/src/commands/community.rs +++ b/src-tauri/src/commands/community.rs @@ -9,6 +9,44 @@ use crate::protocol::community::{CategoryMeta, ChannelKind, ChannelMeta, Communi use crate::protocol::messages::PeerStatus; use crate::AppState; +// check if the requester has one of the required roles in the community +fn check_permission( + members: &[Member], + requester_id: &str, + required_roles: &[&str], +) -> Result<(), String> { + let requester = members + .iter() + .find(|m| m.peer_id == requester_id) + .ok_or("requester not found in community")?; + + let has_permission = requester + .roles + .iter() + .any(|r| required_roles.contains(&r.as_str())); + + if !has_permission { + return Err("insufficient permissions".to_string()); + } + + Ok(()) +} + +// helper to broadcast a crdt change to peers via the sync topic +async fn broadcast_sync(state: &State<'_, AppState>, community_id: &str) { + let node_handle = state.node_handle.lock().await; + if let Some(ref handle) = *node_handle { + let sync_topic = "dusk/sync".to_string(); + let _ = handle + .command_tx + .send(NodeCommand::SendMessage { + topic: sync_topic, + data: community_id.as_bytes().to_vec(), + }) + .await; + } +} + #[tauri::command] pub async fn create_community( state: State<'_, AppState>, @@ -515,19 +553,230 @@ pub async fn reorder_channels( let channels = engine.reorder_channels(&community_id, &channel_ids)?; drop(engine); - // broadcast the reordering to peers via document sync - // the change will propagate through the existing gossipsub sync mechanism - let node_handle = state.node_handle.lock().await; - if let Some(ref handle) = *node_handle { - let sync_topic = "dusk/sync".to_string(); - let _ = handle - .command_tx - .send(NodeCommand::SendMessage { - topic: sync_topic, - data: community_id.as_bytes().to_vec(), - }) - .await; - } + broadcast_sync(&state, &community_id).await; Ok(channels) } + +#[tauri::command] +pub async fn update_community( + state: State<'_, AppState>, + community_id: String, + name: String, + description: String, +) -> Result { + if name.len() > 64 { + return Err("community name must be 64 characters or fewer".to_string()); + } + if description.len() > 256 { + return Err("description must be 256 characters or fewer".to_string()); + } + if name.trim().is_empty() { + return Err("community name cannot be empty".to_string()); + } + + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + let requester_id = id.peer_id.to_string(); + drop(identity); + + let engine = state.crdt_engine.lock().await; + let members = engine.get_members(&community_id)?; + check_permission(&members, &requester_id, &["owner", "admin"])?; + drop(engine); + + let mut engine = state.crdt_engine.lock().await; + engine.update_community_meta(&community_id, &name, &description)?; + let meta = engine.get_community_meta(&community_id)?; + let _ = state.storage.save_community_meta(&meta); + drop(engine); + + broadcast_sync(&state, &community_id).await; + + Ok(meta) +} + +#[tauri::command] +pub async fn update_channel( + state: State<'_, AppState>, + community_id: String, + channel_id: String, + name: String, + topic: String, +) -> Result { + if name.trim().is_empty() { + return Err("channel name cannot be empty".to_string()); + } + + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + let requester_id = id.peer_id.to_string(); + drop(identity); + + let engine = state.crdt_engine.lock().await; + let members = engine.get_members(&community_id)?; + check_permission(&members, &requester_id, &["owner", "admin"])?; + drop(engine); + + let mut engine = state.crdt_engine.lock().await; + engine.update_channel(&community_id, &channel_id, &name, &topic)?; + let channels = engine.get_channels(&community_id)?; + drop(engine); + + let channel = channels + .into_iter() + .find(|c| c.id == channel_id) + .ok_or("channel not found after update")?; + + broadcast_sync(&state, &community_id).await; + + Ok(channel) +} + +#[tauri::command] +pub async fn delete_channel( + state: State<'_, AppState>, + community_id: String, + channel_id: String, +) -> Result<(), String> { + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + let requester_id = id.peer_id.to_string(); + drop(identity); + + let engine = state.crdt_engine.lock().await; + let members = engine.get_members(&community_id)?; + check_permission(&members, &requester_id, &["owner", "admin"])?; + drop(engine); + + // unsubscribe from channel topics before deletion + let node_handle = state.node_handle.lock().await; + if let Some(ref handle) = *node_handle { + let msg_topic = gossip::topic_for_messages(&community_id, &channel_id); + let _ = handle + .command_tx + .send(NodeCommand::Unsubscribe { topic: msg_topic }) + .await; + let typing_topic = gossip::topic_for_typing(&community_id, &channel_id); + let _ = handle + .command_tx + .send(NodeCommand::Unsubscribe { + topic: typing_topic, + }) + .await; + } + drop(node_handle); + + let mut engine = state.crdt_engine.lock().await; + engine.delete_channel(&community_id, &channel_id)?; + drop(engine); + + broadcast_sync(&state, &community_id).await; + + Ok(()) +} + +#[tauri::command] +pub async fn delete_category( + state: State<'_, AppState>, + community_id: String, + category_id: String, +) -> Result<(), String> { + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + let requester_id = id.peer_id.to_string(); + drop(identity); + + let engine = state.crdt_engine.lock().await; + let members = engine.get_members(&community_id)?; + check_permission(&members, &requester_id, &["owner", "admin"])?; + drop(engine); + + let mut engine = state.crdt_engine.lock().await; + engine.delete_category(&community_id, &category_id)?; + drop(engine); + + broadcast_sync(&state, &community_id).await; + + Ok(()) +} + +#[tauri::command] +pub async fn set_member_role( + state: State<'_, AppState>, + community_id: String, + member_peer_id: String, + role: String, +) -> Result<(), String> { + if role != "admin" && role != "member" { + return Err("invalid role: must be 'admin' or 'member'".to_string()); + } + + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + let requester_id = id.peer_id.to_string(); + drop(identity); + + // only the owner can change roles + let engine = state.crdt_engine.lock().await; + let members = engine.get_members(&community_id)?; + check_permission(&members, &requester_id, &["owner"])?; + + // cannot change the owner's own role through this command + let target = members + .iter() + .find(|m| m.peer_id == member_peer_id) + .ok_or("member not found")?; + + if target.roles.iter().any(|r| r == "owner") { + return Err("cannot change the owner's role, use transfer_ownership instead".to_string()); + } + + drop(engine); + + let mut engine = state.crdt_engine.lock().await; + engine.set_member_role(&community_id, &member_peer_id, &[role])?; + drop(engine); + + broadcast_sync(&state, &community_id).await; + + Ok(()) +} + +#[tauri::command] +pub async fn transfer_ownership( + state: State<'_, AppState>, + community_id: String, + new_owner_peer_id: String, +) -> Result<(), String> { + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + let requester_id = id.peer_id.to_string(); + drop(identity); + + let engine = state.crdt_engine.lock().await; + let members = engine.get_members(&community_id)?; + check_permission(&members, &requester_id, &["owner"])?; + + if requester_id == new_owner_peer_id { + return Err("cannot transfer ownership to yourself".to_string()); + } + + // verify the target is actually a member + members + .iter() + .find(|m| m.peer_id == new_owner_peer_id) + .ok_or("target member not found in community")?; + + drop(engine); + + let mut engine = state.crdt_engine.lock().await; + engine.transfer_ownership(&community_id, &requester_id, &new_owner_peer_id)?; + let meta = engine.get_community_meta(&community_id)?; + let _ = state.storage.save_community_meta(&meta); + drop(engine); + + broadcast_sync(&state, &community_id).await; + + Ok(()) +} diff --git a/src-tauri/src/crdt/document.rs b/src-tauri/src/crdt/document.rs index c17907e..b499632 100644 --- a/src-tauri/src/crdt/document.rs +++ b/src-tauri/src/crdt/document.rs @@ -564,3 +564,135 @@ pub fn remove_member(doc: &mut AutoCommit, peer_id: &str) -> Result<(), String> Ok(()) } + +// update the community name and description in the meta map +pub fn update_community_meta( + doc: &mut AutoCommit, + name: &str, + description: &str, +) -> Result<(), automerge::AutomergeError> { + let meta = doc + .get(ROOT, "meta")? + .map(|(_, id)| id) + .ok_or_else(|| automerge::AutomergeError::InvalidObjId("meta not found".to_string()))?; + + doc.put(&meta, "name", name)?; + doc.put(&meta, "description", description)?; + + Ok(()) +} + +// update a channel's name and topic +pub fn update_channel( + doc: &mut AutoCommit, + channel_id: &str, + name: &str, + topic: &str, +) -> Result<(), automerge::AutomergeError> { + let channels = doc + .get(ROOT, "channels")? + .map(|(_, id)| id) + .ok_or_else(|| automerge::AutomergeError::InvalidObjId("channels not found".to_string()))?; + + let channel = doc + .get(&channels, channel_id)? + .map(|(_, id)| id) + .ok_or_else(|| automerge::AutomergeError::InvalidObjId("channel not found".to_string()))?; + + doc.put(&channel, "name", name)?; + doc.put(&channel, "topic", topic)?; + + Ok(()) +} + +// remove a channel and all its messages from the document +pub fn delete_channel( + doc: &mut AutoCommit, + channel_id: &str, +) -> Result<(), automerge::AutomergeError> { + let channels = doc + .get(ROOT, "channels")? + .map(|(_, id)| id) + .ok_or_else(|| automerge::AutomergeError::InvalidObjId("channels not found".to_string()))?; + + doc.delete(&channels, channel_id)?; + + Ok(()) +} + +// remove a category and ungroup any channels that referenced it +pub fn delete_category( + doc: &mut AutoCommit, + category_id: &str, +) -> Result<(), automerge::AutomergeError> { + let categories = doc + .get(ROOT, "categories")? + .map(|(_, id)| id) + .ok_or_else(|| { + automerge::AutomergeError::InvalidObjId("categories not found".to_string()) + })?; + + doc.delete(&categories, category_id)?; + + // clear category_id on any channels that were in this category + if let Some((_, channels_id)) = doc.get(ROOT, "channels")? { + let keys: Vec = doc.keys(&channels_id).collect(); + for key in keys { + if let Some((_, ch_id)) = doc.get(&channels_id, &key)? { + if let Some(cat_id) = get_str(doc, &ch_id, "category_id") { + if cat_id == category_id { + doc.delete(&ch_id, "category_id")?; + } + } + } + } + } + + Ok(()) +} + +// replace a member's roles list with the given roles +pub fn set_member_role( + doc: &mut AutoCommit, + peer_id: &str, + roles: &[String], +) -> Result<(), automerge::AutomergeError> { + let members = doc + .get(ROOT, "members")? + .map(|(_, id)| id) + .ok_or_else(|| automerge::AutomergeError::InvalidObjId("members not found".to_string()))?; + + let member = doc + .get(&members, peer_id)? + .map(|(_, id)| id) + .ok_or_else(|| automerge::AutomergeError::InvalidObjId("member not found".to_string()))?; + + // remove existing roles list and recreate with new roles + doc.delete(&member, "roles")?; + let roles_list = doc.put_object(&member, "roles", ObjType::List)?; + for (i, role) in roles.iter().enumerate() { + doc.insert(&roles_list, i, role.as_str())?; + } + + Ok(()) +} + +// transfer ownership from one member to another +// demotes old owner to admin, promotes new member to owner, updates meta.created_by +pub fn transfer_ownership( + doc: &mut AutoCommit, + old_owner_id: &str, + new_owner_id: &str, +) -> Result<(), automerge::AutomergeError> { + set_member_role(doc, old_owner_id, &["admin".to_string()])?; + set_member_role(doc, new_owner_id, &["owner".to_string()])?; + + let meta = doc + .get(ROOT, "meta")? + .map(|(_, id)| id) + .ok_or_else(|| automerge::AutomergeError::InvalidObjId("meta not found".to_string()))?; + + doc.put(&meta, "created_by", new_owner_id)?; + + Ok(()) +} diff --git a/src-tauri/src/crdt/mod.rs b/src-tauri/src/crdt/mod.rs index 55b616d..7b90531 100644 --- a/src-tauri/src/crdt/mod.rs +++ b/src-tauri/src/crdt/mod.rs @@ -296,6 +296,115 @@ impl CrdtEngine { self.documents.get_mut(community_id).map(|doc| doc.save()) } + // update community name and description + pub fn update_community_meta( + &mut self, + community_id: &str, + name: &str, + description: &str, + ) -> Result<(), String> { + let doc = self + .documents + .get_mut(community_id) + .ok_or("community not found")?; + + document::update_community_meta(doc, name, description) + .map_err(|e| format!("failed to update community meta: {}", e))?; + + self.persist(community_id)?; + Ok(()) + } + + // update a channel's name and topic + pub fn update_channel( + &mut self, + community_id: &str, + channel_id: &str, + name: &str, + topic: &str, + ) -> Result<(), String> { + let doc = self + .documents + .get_mut(community_id) + .ok_or("community not found")?; + + document::update_channel(doc, channel_id, name, topic) + .map_err(|e| format!("failed to update channel: {}", e))?; + + self.persist(community_id)?; + Ok(()) + } + + // remove a channel from a community + pub fn delete_channel(&mut self, community_id: &str, channel_id: &str) -> Result<(), String> { + let doc = self + .documents + .get_mut(community_id) + .ok_or("community not found")?; + + document::delete_channel(doc, channel_id) + .map_err(|e| format!("failed to delete channel: {}", e))?; + + self.persist(community_id)?; + Ok(()) + } + + // remove a category and ungroup its channels + pub fn delete_category( + &mut self, + community_id: &str, + category_id: &str, + ) -> Result<(), String> { + let doc = self + .documents + .get_mut(community_id) + .ok_or("community not found")?; + + document::delete_category(doc, category_id) + .map_err(|e| format!("failed to delete category: {}", e))?; + + self.persist(community_id)?; + Ok(()) + } + + // replace a member's roles + pub fn set_member_role( + &mut self, + community_id: &str, + peer_id: &str, + roles: &[String], + ) -> Result<(), String> { + let doc = self + .documents + .get_mut(community_id) + .ok_or("community not found")?; + + document::set_member_role(doc, peer_id, roles) + .map_err(|e| format!("failed to set member role: {}", e))?; + + self.persist(community_id)?; + Ok(()) + } + + // transfer ownership from one member to another + pub fn transfer_ownership( + &mut self, + community_id: &str, + old_owner_id: &str, + new_owner_id: &str, + ) -> Result<(), String> { + let doc = self + .documents + .get_mut(community_id) + .ok_or("community not found")?; + + document::transfer_ownership(doc, old_owner_id, new_owner_id) + .map_err(|e| format!("failed to transfer ownership: {}", e))?; + + self.persist(community_id)?; + Ok(()) + } + // drop all in-memory documents (used during identity reset) pub fn clear(&mut self) { self.documents.clear(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62a7fe5..aced06d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -131,6 +131,12 @@ pub fn run() { commands::community::reorder_channels, commands::community::create_category, commands::community::get_categories, + commands::community::update_community, + commands::community::update_channel, + commands::community::delete_channel, + commands::community::delete_category, + commands::community::set_member_role, + commands::community::transfer_ownership, commands::voice::join_voice_channel, commands::voice::leave_voice_channel, commands::voice::update_voice_media_state, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 08d5eca..f6d7aa2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -21,7 +21,7 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' asset: http://asset.localhost data: https://static.klipy.com; connect-src ipc: http://ipc.localhost; worker-src 'none'; object-src 'none'; base-uri 'self'" + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' asset: http://asset.localhost data: https://static.klipy.com https://*.tenor.com https://media.tenor.com https://media1.tenor.com https://c.tenor.com; connect-src ipc: http://ipc.localhost; worker-src 'none'; object-src 'none'; base-uri 'self'" } }, "bundle": { diff --git a/src/App.tsx b/src/App.tsx index 1ac26ed..32ac792 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,16 +14,19 @@ import MobileNav from "./components/navigation/MobileNav"; import Modal from "./components/common/Modal"; import Button from "./components/common/Button"; import SettingsModal from "./components/settings/SettingsModal"; +import CommunitySettingsModal from "./components/settings/CommunitySettingsModal"; import SignUpScreen from "./components/auth/SignUpScreen"; import SplashScreen from "./components/auth/SplashScreen"; import UserDirectoryModal from "./components/directory/UserDirectoryModal"; import ProfileCard from "./components/common/ProfileCard"; import ProfileModal from "./components/common/ProfileModal"; +import { AlertTriangle } from "lucide-solid"; import { overlayMenuOpen, closeOverlay, activeModal, + modalData, closeModal, openModal, initResponsive, @@ -35,6 +38,8 @@ import { setActiveCommunity, activeCommunityId, addCommunity, + activeCommunity, + removeCommunity, } from "./stores/communities"; import { setChannels, @@ -51,6 +56,7 @@ import { removeMessage, } from "./stores/messages"; import { + members, setMembers, addTypingPeer, setPeerOnline, @@ -122,6 +128,9 @@ const App: Component = () => { string | null >(null); const [newCategoryName, setNewCategoryName] = createSignal(""); + const [inviteCode, setInviteCode] = createSignal(""); + const [inviteLoading, setInviteLoading] = createSignal(false); + const [inviteCopied, setInviteCopied] = createSignal(false); // react to community switches by loading channels, members, and selecting first channel createEffect( @@ -205,6 +214,15 @@ const App: Component = () => { }), ); + // automatically generate invite code when the invite modal opens + createEffect( + on(activeModal, (modal) => { + if (modal === "invite-people") { + handleOpenInvite(); + } + }), + ); + onMount(async () => { cleanupResize = initResponsive(); @@ -678,6 +696,80 @@ const App: Component = () => { closeModal(); } + // generates an invite code for the active community and opens the modal + async function handleOpenInvite() { + const communityId = activeCommunityId(); + if (!communityId) return; + + setInviteCode(""); + setInviteCopied(false); + setInviteLoading(true); + + if (tauriAvailable()) { + try { + const code = await tauri.generateInvite(communityId); + setInviteCode(code); + } catch (e) { + console.error("failed to generate invite:", e); + } + } else { + // demo mode - simulate invite code generation + setInviteCode("dusk_demo_invite_" + communityId.slice(4, 16)); + } + + setInviteLoading(false); + } + + async function handleCopyInvite() { + const code = inviteCode(); + if (!code) return; + try { + await navigator.clipboard.writeText(code); + setInviteCopied(true); + setTimeout(() => setInviteCopied(false), 2000); + } catch { + // fallback for nonsecure contexts + const textarea = document.createElement("textarea"); + textarea.value = code; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + setInviteCopied(true); + setTimeout(() => setInviteCopied(false), 2000); + } + } + + // check if the current user is the owner of the active community + const isCurrentUserOwner = () => { + const id = identity(); + const memberList = members(); + if (!id) return false; + const self = memberList.find((m) => m.peer_id === id.peer_id); + return self?.roles.includes("owner") ?? false; + }; + + async function handleLeaveServer() { + const communityId = activeCommunityId(); + if (!communityId) return; + + if (tauriAvailable()) { + try { + await tauri.leaveCommunity(communityId); + } catch (e) { + console.error("failed to leave community:", e); + } + } + + removeCommunity(communityId); + setChannels([]); + setCategories([]); + setActiveChannel(null); + clearMessages(); + setMembers([]); + closeModal(); + } + async function handleSaveSettings() { if (tauriAvailable()) { try { @@ -1005,10 +1097,152 @@ const App: Component = () => { onResetIdentity={handleResetIdentity} /> + + + + {/* invite people modal */} + +
+

+ share this invite code with others so they can join{" "} + + {activeCommunity()?.name ?? "this server"} + +

+ + + generating... + +
+ } + > +
+ e.currentTarget.select()} + /> + +
+ +

+ invite codes never contain IP addresses. peers discover each other + through the relay. +

+ +
+ + {/* leave server confirmation modal */} + + +

+ are you sure you want to leave{" "} + + {activeCommunity()?.name ?? "this server"} + + ? you can rejoin later with a new invite code. +

+
+ + +
+ + } + > +
+
+
+ +
+

+ you are the owner of{" "} + + {activeCommunity()?.name ?? "this server"} + +

+

+ if you leave without transferring ownership, no one will + have owner permissions. consider transferring ownership to + another member first. +

+
+
+
+ +
+ + + +
+
+
+
); diff --git a/src/components/chat/Message.tsx b/src/components/chat/Message.tsx index 256945f..9d3bd9a 100644 --- a/src/components/chat/Message.tsx +++ b/src/components/chat/Message.tsx @@ -1,12 +1,14 @@ import type { Component } from "solid-js"; import { Show, createSignal, createMemo } from "solid-js"; import type { ChatMessage } from "../../lib/types"; -import { formatTime, formatTimeShort } from "../../lib/utils"; -import { renderMarkdown, isStandaloneImageUrl } from "../../lib/markdown"; +import { formatTime } from "../../lib/utils"; +import { renderMarkdown, getStandaloneMediaKind } from "../../lib/markdown"; +import type { MediaKind } from "../../lib/markdown"; import { removeMessage } from "../../stores/messages"; import { activeCommunityId } from "../../stores/communities"; import { identity } from "../../stores/identity"; import Avatar from "../common/Avatar"; +import Lightbox from "../common/Lightbox"; import { openProfileCard } from "../../stores/ui"; import * as tauri from "../../lib/tauri"; @@ -14,6 +16,7 @@ interface MessageProps { message: ChatMessage; isGrouped: boolean; isFirstInGroup: boolean; + isLastInGroup: boolean; } const Message: Component = (props) => { @@ -29,7 +32,11 @@ const Message: Component = (props) => { const renderedContent = createMemo(() => renderMarkdown(props.message.content), ); - const isImage = createMemo(() => isStandaloneImageUrl(props.message.content)); + const mediaKind = createMemo(() => + getStandaloneMediaKind(props.message.content), + ); + + const [lightboxOpen, setLightboxOpen] = createSignal(false); const isOwner = () => { const user = currentUser(); @@ -69,6 +76,15 @@ const Message: Component = (props) => { }); } + // opens lightbox when clicking any media element in the message + function handleMediaClick(e: MouseEvent) { + const target = e.target as HTMLElement; + if (target.classList.contains("dusk-media-clickable")) { + e.stopPropagation(); + setLightboxOpen(true); + } + } + // close context menu on click outside if (typeof window !== "undefined") { window.addEventListener("click", closeContextMenu); @@ -76,24 +92,18 @@ const Message: Component = (props) => { return (
- - {formatTimeShort(props.message.timestamp)} - -
- } + fallback={
} >
+ {/* media lightbox */} + + setLightboxOpen(false)} + src={props.message.content.trim()} + type={mediaKind()!} + /> + + {/* context menu */} {(menu) => ( diff --git a/src/components/chat/MessageList.tsx b/src/components/chat/MessageList.tsx index 727c159..62e6943 100644 --- a/src/components/chat/MessageList.tsx +++ b/src/components/chat/MessageList.tsx @@ -1,7 +1,11 @@ import type { Component } from "solid-js"; -import { For, Show, createEffect, createSignal, onMount } from "solid-js"; +import { For, Show, createEffect, createSignal, onMount, untrack } from "solid-js"; import type { ChatMessage } from "../../lib/types"; -import { isWithinGroupWindow, isDifferentDay, formatDaySeparator } from "../../lib/utils"; +import { + isWithinGroupWindow, + isDifferentDay, + formatDaySeparator, +} from "../../lib/utils"; import Message from "./Message"; import { ArrowDown } from "lucide-solid"; @@ -14,6 +18,8 @@ const MessageList: Component = (props) => { let containerRef: HTMLDivElement | undefined; const [showScrollButton, setShowScrollButton] = createSignal(false); const [isAtBottom, setIsAtBottom] = createSignal(true); + const [prevMessageCount, setPrevMessageCount] = createSignal(0); + const [shouldAnimateLast, setShouldAnimateLast] = createSignal(false); function scrollToBottom(smooth = true) { if (containerRef) { @@ -33,6 +39,19 @@ const MessageList: Component = (props) => { setShowScrollButton(!atBottom); } + // track when messages are actually added vs view changes + createEffect(() => { + const currentCount = props.messages.length; + const prevCount = untrack(() => prevMessageCount()); + + if (currentCount > prevCount && prevCount > 0) { + setShouldAnimateLast(true); + } else { + setShouldAnimateLast(false); + } + setPrevMessageCount(currentCount); + }); + // auto-scroll when new messages arrive if user is at the bottom createEffect(() => { void props.messages.length; @@ -54,11 +73,15 @@ const MessageList: Component = (props) => { class="h-full overflow-y-auto" onScroll={handleScroll} > -
+
{(message, index) => { const prev = () => index() > 0 ? props.messages[index() - 1] : undefined; + const next = () => + index() < props.messages.length - 1 + ? props.messages[index() + 1] + : undefined; const isFirstInGroup = () => { const p = prev(); if (!p) return true; @@ -67,6 +90,14 @@ const MessageList: Component = (props) => { return true; return false; }; + const isLastInGroup = () => { + const n = next(); + if (!n) return true; + if (n.author_id !== message.author_id) return true; + if (!isWithinGroupWindow(message.timestamp, n.timestamp)) + return true; + return false; + }; const isGrouped = () => !isFirstInGroup(); const showDaySeparator = () => { const p = prev(); @@ -74,10 +105,12 @@ const MessageList: Component = (props) => { return isDifferentDay(p.timestamp, message.timestamp); }; + const isLastMessage = () => index() === props.messages.length - 1; + return ( <> -
+
{formatDaySeparator(message.timestamp)} @@ -85,11 +118,12 @@ const MessageList: Component = (props) => {
-
+
diff --git a/src/components/common/DropdownMenu.tsx b/src/components/common/DropdownMenu.tsx new file mode 100644 index 0000000..a0142ce --- /dev/null +++ b/src/components/common/DropdownMenu.tsx @@ -0,0 +1,111 @@ +import type { Component } from "solid-js"; +import { For, Show, onMount, onCleanup, createSignal } from "solid-js"; +import { Portal, Dynamic } from "solid-js/web"; + +export interface DropdownItem { + label: string; + icon?: Component<{ size?: number; class?: string }>; + onClick: () => void; + // visually distinct destructive action (red text) + destructive?: boolean; + // divider rendered above this item + dividerAbove?: boolean; +} + +interface DropdownMenuProps { + items: DropdownItem[]; + isOpen: boolean; + onClose: () => void; + // anchor element ref for positioning + anchorRef?: HTMLElement; +} + +const DropdownMenu: Component = (props) => { + let menuRef: HTMLDivElement | undefined; + const [position, setPosition] = createSignal({ top: 0, left: 0 }); + + // recompute position whenever the dropdown opens + const updatePosition = () => { + if (!props.anchorRef || !menuRef) return; + const rect = props.anchorRef.getBoundingClientRect(); + // position directly below the anchor, aligned to the left edge + setPosition({ + top: rect.bottom + 4, + left: rect.left, + }); + }; + + // close on outside click + const handleClickOutside = (e: MouseEvent) => { + if (menuRef && !menuRef.contains(e.target as Node)) { + props.onClose(); + } + }; + + const handleKeydown = (e: KeyboardEvent) => { + if (e.key === "Escape") props.onClose(); + }; + + onMount(() => { + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleKeydown); + }); + + onCleanup(() => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleKeydown); + }); + + // recalculate on open + const isOpen = () => { + if (props.isOpen) { + // defer to let the element render first + requestAnimationFrame(updatePosition); + } + return props.isOpen; + }; + + return ( + + +
+ + {(item) => ( + <> + +
+ + + + )} + +
+ +
+ ); +}; + +export default DropdownMenu; diff --git a/src/components/common/Lightbox.tsx b/src/components/common/Lightbox.tsx new file mode 100644 index 0000000..c7a88e3 --- /dev/null +++ b/src/components/common/Lightbox.tsx @@ -0,0 +1,173 @@ +import type { Component } from "solid-js"; +import { Show, createSignal, createEffect, onMount, onCleanup } from "solid-js"; +import { Portal } from "solid-js/web"; +import { X, ZoomIn, ZoomOut, RotateCcw } from "lucide-solid"; + +interface LightboxProps { + isOpen: boolean; + onClose: () => void; + src: string; + // "image" or "video" - determines how the media is rendered + type: "image" | "video"; + alt?: string; +} + +const Lightbox: Component = (props) => { + const [scale, setScale] = createSignal(1); + const [translate, setTranslate] = createSignal({ x: 0, y: 0 }); + const [dragging, setDragging] = createSignal(false); + const [dragStart, setDragStart] = createSignal({ x: 0, y: 0 }); + + function resetTransform() { + setScale(1); + setTranslate({ x: 0, y: 0 }); + } + + function handleKeydown(e: KeyboardEvent) { + if (e.key === "Escape") props.onClose(); + if (e.key === "+" || e.key === "=") zoomIn(); + if (e.key === "-") zoomOut(); + if (e.key === "0") resetTransform(); + } + + function handleBackdropClick(e: MouseEvent) { + if (e.target === e.currentTarget) props.onClose(); + } + + function handleWheel(e: WheelEvent) { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.15 : 0.15; + setScale((prev) => Math.max(0.25, Math.min(5, prev + delta))); + } + + function handlePointerDown(e: PointerEvent) { + // only pan images, not videos + if (props.type === "video") return; + e.preventDefault(); + setDragging(true); + setDragStart({ + x: e.clientX - translate().x, + y: e.clientY - translate().y, + }); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + } + + function handlePointerMove(e: PointerEvent) { + if (!dragging()) return; + setTranslate({ + x: e.clientX - dragStart().x, + y: e.clientY - dragStart().y, + }); + } + + function handlePointerUp() { + setDragging(false); + } + + function zoomIn() { + setScale((prev) => Math.min(5, prev + 0.25)); + } + + function zoomOut() { + setScale((prev) => Math.max(0.25, prev - 0.25)); + } + + onMount(() => { + document.addEventListener("keydown", handleKeydown); + }); + + onCleanup(() => { + document.removeEventListener("keydown", handleKeydown); + }); + + // reset transform state when the lightbox opens + createEffect(() => { + if (props.isOpen) resetTransform(); + }); + + return ( + + +
+ {/* toolbar */} +
+ + + + +
+ + +
+ + {/* media container */} +
+ e.stopPropagation()} + > + + + } + > + {props.alt e.stopPropagation()} + /> + +
+
+ + + ); +}; + +export default Lightbox; diff --git a/src/components/common/UserFooter.tsx b/src/components/common/UserFooter.tsx index 93f45a7..32c4f36 100644 --- a/src/components/common/UserFooter.tsx +++ b/src/components/common/UserFooter.tsx @@ -65,7 +65,7 @@ const UserFooter: Component = (props) => { }); return ( -
+
{ return all; }; + // server dropdown state + const [serverDropdownOpen, setServerDropdownOpen] = createSignal(false); + let headerRef: HTMLDivElement | undefined; + + const serverDropdownItems = (): DropdownItem[] => [ + { + label: "server settings", + icon: Settings, + onClick: () => { + const comm = community(); + if (comm) openModal("community-settings", { communityId: comm.id }); + }, + }, + { + label: "invite people", + icon: UserPlus, + onClick: () => openModal("invite-people"), + }, + { + label: "leave server", + icon: LogOut, + onClick: () => openModal("leave-server"), + destructive: true, + dividerAbove: true, + }, + ]; + const handleChannelClick = (channel: ChannelMeta) => { if (channel.kind === "Voice") { // clicking a voice channel joins it (or switches to it) @@ -350,7 +382,13 @@ const ChannelList: Component = () => { const header = (
-
+
{ + if (community()) setServerDropdownOpen((v) => !v); + }} + > { {community()!.name} - + +
+ +
+
+ setServerDropdownOpen(false)} + anchorRef={headerRef} + />
); diff --git a/src/components/layout/HomeView.tsx b/src/components/layout/HomeView.tsx index 007c8e2..5338aff 100644 --- a/src/components/layout/HomeView.tsx +++ b/src/components/layout/HomeView.tsx @@ -282,18 +282,7 @@ const HomeView: Component = () => {
- {/* status bar at the bottom */} -
- - {nodeStatus() === "running" - ? peerCount() > 0 - ? `connected - ${peerCount()} peers on network` - : "searching for peers..." - : nodeStatus() === "starting" - ? "connecting to network..." - : "offline"} - -
+
); }; diff --git a/src/components/settings/CommunitySettingsModal.tsx b/src/components/settings/CommunitySettingsModal.tsx new file mode 100644 index 0000000..de7cdca --- /dev/null +++ b/src/components/settings/CommunitySettingsModal.tsx @@ -0,0 +1,1264 @@ +import { Component, createSignal, createEffect, For, Show } from "solid-js"; +import { Portal } from "solid-js/web"; +import { + X, + Info, + Hash, + Volume2, + Users, + UserPlus, + AlertTriangle, + Copy, + Check, + Pencil, + Trash2, + Shield, + Crown, + ChevronDown, +} from "lucide-solid"; +import { identity } from "../../stores/identity"; +import { + activeCommunity, + updateCommunityMeta, + removeCommunity, +} from "../../stores/communities"; +import { + channels, + categories, + removeChannel, + removeCategory, + updateChannelMeta, + setChannels, + setCategories, + setActiveChannel, +} from "../../stores/channels"; +import { + members, + removeMember, + updateMemberRole, + setMembers, +} from "../../stores/members"; +import { clearMessages } from "../../stores/messages"; +import * as tauri from "../../lib/tauri"; +import Avatar from "../common/Avatar"; +import Button from "../common/Button"; +import type { ChannelMeta, CategoryMeta, Member } from "../../lib/types"; + +type CommunitySettingsSection = + | "overview" + | "channels" + | "members" + | "invites" + | "danger"; + +interface CommunitySettingsModalProps { + isOpen: boolean; + onClose: () => void; + communityId: string | null; + initialSection?: CommunitySettingsSection; +} + +const CommunitySettingsModal: Component = ( + props, +) => { + const [activeSection, setActiveSection] = + createSignal("overview"); + + // derive the local user's highest role + const localUserRole = () => { + const id = identity(); + const memberList = members(); + if (!id) return "member"; + const member = memberList.find((m) => m.peer_id === id.peer_id); + if (!member) return "member"; + if (member.roles.includes("owner")) return "owner"; + if (member.roles.includes("admin")) return "admin"; + return "member"; + }; + + const isOwner = () => localUserRole() === "owner"; + const isAdmin = () => + localUserRole() === "owner" || localUserRole() === "admin"; + + // reset to initial tab when modal opens + createEffect(() => { + if (props.isOpen) { + setActiveSection(props.initialSection ?? "overview"); + } + }); + + const community = () => activeCommunity(); + + const sections: { + id: CommunitySettingsSection; + label: string; + icon: typeof Info; + }[] = [ + { id: "overview", label: "overview", icon: Info }, + { id: "channels", label: "channels", icon: Hash }, + { id: "members", label: "members", icon: Users }, + { id: "invites", label: "invites", icon: UserPlus }, + { id: "danger", label: "danger zone", icon: AlertTriangle }, + ]; + + return ( + + +
+
+ {/* sidebar navigation */} +
+
+

+ server settings +

+
+ +
+ + {/* main content */} +
+ {/* header */} +
+

+ {activeSection() === "danger" + ? "danger zone" + : activeSection()} +

+ +
+ + {/* content */} +
+ + + + + + + + + + + + + + + + + + setActiveSection("members")} + /> + +
+
+
+
+
+
+ ); +}; + +// -- overview section -- + +interface OverviewSectionProps { + communityId: string | null; + isAdmin: boolean; + onClose: () => void; +} + +const OverviewSection: Component = (props) => { + const [localName, setLocalName] = createSignal(""); + const [localDescription, setLocalDescription] = createSignal(""); + const [copied, setCopied] = createSignal(false); + const [saving, setSaving] = createSignal(false); + + const community = () => activeCommunity(); + + // sync local state from the community when it changes + createEffect(() => { + const comm = community(); + if (comm) { + setLocalName(comm.name); + setLocalDescription(comm.description); + } + }); + + async function handleSave() { + const communityId = props.communityId; + if (!communityId) return; + + const name = localName().trim(); + if (!name) return; + + setSaving(true); + try { + const updated = await tauri.updateCommunity( + communityId, + name, + localDescription().trim(), + ); + updateCommunityMeta(communityId, { + name: updated.name, + description: updated.description, + }); + } catch (e) { + console.error("failed to update community:", e); + } + setSaving(false); + } + + function copyId() { + const comm = community(); + if (comm?.id) { + navigator.clipboard.writeText(comm.id); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + } + + const hasChanges = () => { + const comm = community(); + if (!comm) return false; + return ( + localName().trim() !== comm.name || + localDescription().trim() !== comm.description + ); + }; + + const createdDate = () => { + const comm = community(); + if (!comm) return ""; + return new Date(comm.created_at).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + }; + + return ( +
+ {/* community name */} +
+ + setLocalName(e.currentTarget.value)} + maxLength={64} + disabled={!props.isAdmin} + /> +

+ {localName().length}/64 characters +

+
+ + {/* description */} +
+ +