feat: add CommunitySettingsModal component for managing community settings

This commit is contained in:
cloudwithax 2026-02-15 18:45:36 -05:00
parent 26c1563ea9
commit 4bf42706c3
20 changed files with 2669 additions and 58 deletions

View File

@ -9,6 +9,44 @@ use crate::protocol::community::{CategoryMeta, ChannelKind, ChannelMeta, Communi
use crate::protocol::messages::PeerStatus; use crate::protocol::messages::PeerStatus;
use crate::AppState; 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] #[tauri::command]
pub async fn create_community( pub async fn create_community(
state: State<'_, AppState>, state: State<'_, AppState>,
@ -515,19 +553,230 @@ pub async fn reorder_channels(
let channels = engine.reorder_channels(&community_id, &channel_ids)?; let channels = engine.reorder_channels(&community_id, &channel_ids)?;
drop(engine); drop(engine);
// broadcast the reordering to peers via document sync broadcast_sync(&state, &community_id).await;
// 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;
}
Ok(channels) Ok(channels)
} }
#[tauri::command]
pub async fn update_community(
state: State<'_, AppState>,
community_id: String,
name: String,
description: String,
) -> Result<CommunityMeta, String> {
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<ChannelMeta, String> {
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(())
}

View File

@ -564,3 +564,135 @@ pub fn remove_member(doc: &mut AutoCommit, peer_id: &str) -> Result<(), String>
Ok(()) 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<String> = 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(())
}

View File

@ -296,6 +296,115 @@ impl CrdtEngine {
self.documents.get_mut(community_id).map(|doc| doc.save()) 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) // drop all in-memory documents (used during identity reset)
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.documents.clear(); self.documents.clear();

View File

@ -131,6 +131,12 @@ pub fn run() {
commands::community::reorder_channels, commands::community::reorder_channels,
commands::community::create_category, commands::community::create_category,
commands::community::get_categories, 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::join_voice_channel,
commands::voice::leave_voice_channel, commands::voice::leave_voice_channel,
commands::voice::update_voice_media_state, commands::voice::update_voice_media_state,

View File

@ -21,7 +21,7 @@
} }
], ],
"security": { "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": { "bundle": {

View File

@ -14,16 +14,19 @@ import MobileNav from "./components/navigation/MobileNav";
import Modal from "./components/common/Modal"; import Modal from "./components/common/Modal";
import Button from "./components/common/Button"; import Button from "./components/common/Button";
import SettingsModal from "./components/settings/SettingsModal"; import SettingsModal from "./components/settings/SettingsModal";
import CommunitySettingsModal from "./components/settings/CommunitySettingsModal";
import SignUpScreen from "./components/auth/SignUpScreen"; import SignUpScreen from "./components/auth/SignUpScreen";
import SplashScreen from "./components/auth/SplashScreen"; import SplashScreen from "./components/auth/SplashScreen";
import UserDirectoryModal from "./components/directory/UserDirectoryModal"; import UserDirectoryModal from "./components/directory/UserDirectoryModal";
import ProfileCard from "./components/common/ProfileCard"; import ProfileCard from "./components/common/ProfileCard";
import ProfileModal from "./components/common/ProfileModal"; import ProfileModal from "./components/common/ProfileModal";
import { AlertTriangle } from "lucide-solid";
import { import {
overlayMenuOpen, overlayMenuOpen,
closeOverlay, closeOverlay,
activeModal, activeModal,
modalData,
closeModal, closeModal,
openModal, openModal,
initResponsive, initResponsive,
@ -35,6 +38,8 @@ import {
setActiveCommunity, setActiveCommunity,
activeCommunityId, activeCommunityId,
addCommunity, addCommunity,
activeCommunity,
removeCommunity,
} from "./stores/communities"; } from "./stores/communities";
import { import {
setChannels, setChannels,
@ -51,6 +56,7 @@ import {
removeMessage, removeMessage,
} from "./stores/messages"; } from "./stores/messages";
import { import {
members,
setMembers, setMembers,
addTypingPeer, addTypingPeer,
setPeerOnline, setPeerOnline,
@ -122,6 +128,9 @@ const App: Component = () => {
string | null string | null
>(null); >(null);
const [newCategoryName, setNewCategoryName] = createSignal(""); 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 // react to community switches by loading channels, members, and selecting first channel
createEffect( 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 () => { onMount(async () => {
cleanupResize = initResponsive(); cleanupResize = initResponsive();
@ -678,6 +696,80 @@ const App: Component = () => {
closeModal(); 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() { async function handleSaveSettings() {
if (tauriAvailable()) { if (tauriAvailable()) {
try { try {
@ -1005,10 +1097,152 @@ const App: Component = () => {
onResetIdentity={handleResetIdentity} onResetIdentity={handleResetIdentity}
/> />
<CommunitySettingsModal
isOpen={activeModal() === "community-settings"}
onClose={closeModal}
communityId={
(modalData() as { communityId: string } | null)?.communityId ?? null
}
initialSection={
((modalData() as { initialSection?: string } | null)
?.initialSection as any) ?? undefined
}
/>
<UserDirectoryModal <UserDirectoryModal
isOpen={activeModal() === "directory"} isOpen={activeModal() === "directory"}
onClose={closeModal} onClose={closeModal}
/> />
{/* invite people modal */}
<Modal
isOpen={activeModal() === "invite-people"}
onClose={closeModal}
title="invite people"
>
<div class="flex flex-col gap-4">
<p class="text-[14px] text-white/60">
share this invite code with others so they can join{" "}
<span class="text-white font-bold">
{activeCommunity()?.name ?? "this server"}
</span>
</p>
<Show
when={!inviteLoading()}
fallback={
<div class="flex items-center justify-center py-6">
<span class="text-[14px] text-white/40 font-mono">
generating...
</span>
</div>
}
>
<div class="flex gap-2">
<input
type="text"
class="flex-1 bg-black border-2 border-white/20 text-white text-[14px] font-mono px-4 py-3 outline-none select-all focus:border-orange transition-colors duration-200"
value={inviteCode()}
readOnly
onClick={(e) => e.currentTarget.select()}
/>
<Button
variant={inviteCopied() ? "secondary" : "primary"}
onClick={handleCopyInvite}
>
{inviteCopied() ? "copied" : "copy"}
</Button>
</div>
</Show>
<p class="text-[12px] text-white/30 font-mono">
invite codes never contain IP addresses. peers discover each other
through the relay.
</p>
</div>
</Modal>
{/* leave server confirmation modal */}
<Modal
isOpen={activeModal() === "leave-server"}
onClose={closeModal}
title="leave server"
>
<Show
when={isCurrentUserOwner()}
fallback={
<div class="flex flex-col gap-6">
<p class="text-[14px] text-white/60">
are you sure you want to leave{" "}
<span class="text-white font-bold">
{activeCommunity()?.name ?? "this server"}
</span>
? you can rejoin later with a new invite code.
</p>
<div class="flex gap-3 justify-end">
<Button variant="secondary" onClick={closeModal}>
cancel
</Button>
<button
type="button"
class="inline-flex items-center justify-center h-12 px-6 text-[14px] font-medium uppercase tracking-[0.05em] bg-red-500 text-white border-none hover:bg-red-600 hover:scale-[0.98] active:scale-[0.96] transition-all duration-200 cursor-pointer select-none"
onClick={handleLeaveServer}
>
leave server
</button>
</div>
</div>
}
>
<div class="flex flex-col gap-6">
<div class="p-4 bg-orange/10 border-2 border-orange/30">
<div class="flex items-start gap-3">
<AlertTriangle
size={20}
class="text-orange mt-0.5 shrink-0"
/>
<div>
<p class="text-[14px] text-white font-medium">
you are the owner of{" "}
<span class="text-orange font-bold">
{activeCommunity()?.name ?? "this server"}
</span>
</p>
<p class="text-[13px] text-white/50 mt-1">
if you leave without transferring ownership, no one will
have owner permissions. consider transferring ownership to
another member first.
</p>
</div>
</div>
</div>
<div class="flex flex-col gap-3">
<button
type="button"
class="w-full h-12 text-[14px] font-medium uppercase tracking-[0.05em] bg-orange text-black hover:bg-orange/90 hover:scale-[0.98] active:scale-[0.96] transition-all duration-200 cursor-pointer select-none"
onClick={() => {
closeModal();
openModal("community-settings", {
communityId: activeCommunityId(),
initialSection: "members",
});
}}
>
transfer ownership
</button>
<button
type="button"
class="w-full h-12 text-[14px] font-medium uppercase tracking-[0.05em] bg-red-500 text-white border-none hover:bg-red-600 hover:scale-[0.98] active:scale-[0.96] transition-all duration-200 cursor-pointer select-none"
onClick={handleLeaveServer}
>
leave anyway
</button>
<Button variant="secondary" onClick={closeModal}>
cancel
</Button>
</div>
</div>
</Show>
</Modal>
</Show> </Show>
</div> </div>
); );

View File

@ -1,12 +1,14 @@
import type { Component } from "solid-js"; import type { Component } from "solid-js";
import { Show, createSignal, createMemo } from "solid-js"; import { Show, createSignal, createMemo } from "solid-js";
import type { ChatMessage } from "../../lib/types"; import type { ChatMessage } from "../../lib/types";
import { formatTime, formatTimeShort } from "../../lib/utils"; import { formatTime } from "../../lib/utils";
import { renderMarkdown, isStandaloneImageUrl } from "../../lib/markdown"; import { renderMarkdown, getStandaloneMediaKind } from "../../lib/markdown";
import type { MediaKind } from "../../lib/markdown";
import { removeMessage } from "../../stores/messages"; import { removeMessage } from "../../stores/messages";
import { activeCommunityId } from "../../stores/communities"; import { activeCommunityId } from "../../stores/communities";
import { identity } from "../../stores/identity"; import { identity } from "../../stores/identity";
import Avatar from "../common/Avatar"; import Avatar from "../common/Avatar";
import Lightbox from "../common/Lightbox";
import { openProfileCard } from "../../stores/ui"; import { openProfileCard } from "../../stores/ui";
import * as tauri from "../../lib/tauri"; import * as tauri from "../../lib/tauri";
@ -14,6 +16,7 @@ interface MessageProps {
message: ChatMessage; message: ChatMessage;
isGrouped: boolean; isGrouped: boolean;
isFirstInGroup: boolean; isFirstInGroup: boolean;
isLastInGroup: boolean;
} }
const Message: Component<MessageProps> = (props) => { const Message: Component<MessageProps> = (props) => {
@ -29,7 +32,11 @@ const Message: Component<MessageProps> = (props) => {
const renderedContent = createMemo(() => const renderedContent = createMemo(() =>
renderMarkdown(props.message.content), renderMarkdown(props.message.content),
); );
const isImage = createMemo(() => isStandaloneImageUrl(props.message.content)); const mediaKind = createMemo<MediaKind>(() =>
getStandaloneMediaKind(props.message.content),
);
const [lightboxOpen, setLightboxOpen] = createSignal(false);
const isOwner = () => { const isOwner = () => {
const user = currentUser(); const user = currentUser();
@ -69,6 +76,15 @@ const Message: Component<MessageProps> = (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 // close context menu on click outside
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.addEventListener("click", closeContextMenu); window.addEventListener("click", closeContextMenu);
@ -76,24 +92,18 @@ const Message: Component<MessageProps> = (props) => {
return ( return (
<div <div
class={`flex gap-4 hover:bg-gray-900 transition-colors duration-200 ${ class={`flex items-start gap-4 hover:bg-gray-900 transition-colors duration-200 px-4 ${
props.isFirstInGroup ? "pt-4 px-4 pb-1" : "px-4 py-0.5" props.isFirstInGroup ? "pt-2" : "pt-0.5"
}`} } ${props.isLastInGroup ? "pb-2" : "pb-0.5"}`}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
<Show <Show
when={props.isFirstInGroup} when={props.isFirstInGroup}
fallback={ fallback={<div class="w-10 shrink-0" />}
<div class="w-10 shrink-0 flex items-start justify-center">
<span class="text-[11px] font-mono text-white/0 hover:text-white/40 transition-colors duration-200 leading-[22px]">
{formatTimeShort(props.message.timestamp)}
</span>
</div>
}
> >
<button <button
type="button" type="button"
class="w-10 shrink-0 pt-0.5 cursor-pointer" class="w-10 shrink-0 cursor-pointer mt-0.5"
onClick={handleProfileClick} onClick={handleProfileClick}
> >
<Avatar name={props.message.author_name} size="md" /> <Avatar name={props.message.author_name} size="md" />
@ -120,11 +130,12 @@ const Message: Component<MessageProps> = (props) => {
</Show> </Show>
<Show <Show
when={!isImage()} when={!mediaKind()}
fallback={ fallback={
<div <div
class="dusk-msg-content dusk-msg-image-wrapper" class={`dusk-msg-content ${mediaKind() === "image" ? "dusk-msg-image-wrapper" : "dusk-msg-video-wrapper"}`}
innerHTML={renderedContent()} innerHTML={renderedContent()}
onClick={handleMediaClick}
/> />
} }
> >
@ -132,6 +143,16 @@ const Message: Component<MessageProps> = (props) => {
</Show> </Show>
</div> </div>
{/* media lightbox */}
<Show when={mediaKind()}>
<Lightbox
isOpen={lightboxOpen()}
onClose={() => setLightboxOpen(false)}
src={props.message.content.trim()}
type={mediaKind()!}
/>
</Show>
{/* context menu */} {/* context menu */}
<Show when={contextMenu()}> <Show when={contextMenu()}>
{(menu) => ( {(menu) => (

View File

@ -1,7 +1,11 @@
import type { Component } from "solid-js"; 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 type { ChatMessage } from "../../lib/types";
import { isWithinGroupWindow, isDifferentDay, formatDaySeparator } from "../../lib/utils"; import {
isWithinGroupWindow,
isDifferentDay,
formatDaySeparator,
} from "../../lib/utils";
import Message from "./Message"; import Message from "./Message";
import { ArrowDown } from "lucide-solid"; import { ArrowDown } from "lucide-solid";
@ -14,6 +18,8 @@ const MessageList: Component<MessageListProps> = (props) => {
let containerRef: HTMLDivElement | undefined; let containerRef: HTMLDivElement | undefined;
const [showScrollButton, setShowScrollButton] = createSignal(false); const [showScrollButton, setShowScrollButton] = createSignal(false);
const [isAtBottom, setIsAtBottom] = createSignal(true); const [isAtBottom, setIsAtBottom] = createSignal(true);
const [prevMessageCount, setPrevMessageCount] = createSignal(0);
const [shouldAnimateLast, setShouldAnimateLast] = createSignal(false);
function scrollToBottom(smooth = true) { function scrollToBottom(smooth = true) {
if (containerRef) { if (containerRef) {
@ -33,6 +39,19 @@ const MessageList: Component<MessageListProps> = (props) => {
setShowScrollButton(!atBottom); 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 // auto-scroll when new messages arrive if user is at the bottom
createEffect(() => { createEffect(() => {
void props.messages.length; void props.messages.length;
@ -54,11 +73,15 @@ const MessageList: Component<MessageListProps> = (props) => {
class="h-full overflow-y-auto" class="h-full overflow-y-auto"
onScroll={handleScroll} onScroll={handleScroll}
> >
<div class="flex flex-col py-4"> <div class="flex flex-col pb-4">
<For each={props.messages}> <For each={props.messages}>
{(message, index) => { {(message, index) => {
const prev = () => const prev = () =>
index() > 0 ? props.messages[index() - 1] : undefined; index() > 0 ? props.messages[index() - 1] : undefined;
const next = () =>
index() < props.messages.length - 1
? props.messages[index() + 1]
: undefined;
const isFirstInGroup = () => { const isFirstInGroup = () => {
const p = prev(); const p = prev();
if (!p) return true; if (!p) return true;
@ -67,6 +90,14 @@ const MessageList: Component<MessageListProps> = (props) => {
return true; return true;
return false; 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 isGrouped = () => !isFirstInGroup();
const showDaySeparator = () => { const showDaySeparator = () => {
const p = prev(); const p = prev();
@ -74,10 +105,12 @@ const MessageList: Component<MessageListProps> = (props) => {
return isDifferentDay(p.timestamp, message.timestamp); return isDifferentDay(p.timestamp, message.timestamp);
}; };
const isLastMessage = () => index() === props.messages.length - 1;
return ( return (
<> <>
<Show when={showDaySeparator()}> <Show when={showDaySeparator()}>
<div class="flex items-center gap-4 px-4 py-2 my-2"> <div class="flex items-center gap-4 px-4 my-4">
<div class="flex-1 border-t border-white/10" /> <div class="flex-1 border-t border-white/10" />
<span class="text-[12px] font-mono text-white/40 uppercase tracking-[0.05em]"> <span class="text-[12px] font-mono text-white/40 uppercase tracking-[0.05em]">
{formatDaySeparator(message.timestamp)} {formatDaySeparator(message.timestamp)}
@ -85,11 +118,12 @@ const MessageList: Component<MessageListProps> = (props) => {
<div class="flex-1 border-t border-white/10" /> <div class="flex-1 border-t border-white/10" />
</div> </div>
</Show> </Show>
<div class="animate-message-in"> <div class={isLastMessage() && shouldAnimateLast() ? "animate-message-in" : ""}>
<Message <Message
message={message} message={message}
isGrouped={isGrouped()} isGrouped={isGrouped()}
isFirstInGroup={isFirstInGroup()} isFirstInGroup={isFirstInGroup()}
isLastInGroup={isLastInGroup()}
/> />
</div> </div>
</> </>

View File

@ -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<DropdownMenuProps> = (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 (
<Show when={isOpen()}>
<Portal>
<div
ref={menuRef}
class="fixed z-999 min-w-50 bg-gray-900 border border-white/10 shadow-xl animate-fade-in py-1"
style={{
top: `${position().top}px`,
left: `${position().left}px`,
}}
>
<For each={props.items}>
{(item) => (
<>
<Show when={item.dividerAbove}>
<div class="h-px bg-white/10 my-1" />
</Show>
<button
type="button"
class={`flex items-center gap-2.5 w-full px-3 py-2 text-[14px] text-left transition-colors duration-150 cursor-pointer ${
item.destructive
? "text-red-400 hover:bg-red-500/10"
: "text-white/80 hover:bg-white/10 hover:text-white"
}`}
onClick={() => {
item.onClick();
props.onClose();
}}
>
<Show when={item.icon}>
<Dynamic component={item.icon} size={16} class="shrink-0" />
</Show>
{item.label}
</button>
</>
)}
</For>
</div>
</Portal>
</Show>
);
};
export default DropdownMenu;

View File

@ -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<LightboxProps> = (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 (
<Show when={props.isOpen}>
<Portal>
<div
class="dusk-lightbox-backdrop"
onClick={handleBackdropClick}
onWheel={handleWheel}
>
{/* toolbar */}
<div class="dusk-lightbox-toolbar">
<Show when={props.type === "image"}>
<button
type="button"
class="dusk-lightbox-btn"
onClick={zoomIn}
title="zoom in (+)"
>
<ZoomIn size={18} />
</button>
<button
type="button"
class="dusk-lightbox-btn"
onClick={zoomOut}
title="zoom out (-)"
>
<ZoomOut size={18} />
</button>
<button
type="button"
class="dusk-lightbox-btn"
onClick={resetTransform}
title="reset (0)"
>
<RotateCcw size={18} />
</button>
<div class="dusk-lightbox-divider" />
</Show>
<button
type="button"
class="dusk-lightbox-btn"
onClick={props.onClose}
title="close (esc)"
>
<X size={18} />
</button>
</div>
{/* media container */}
<div class="dusk-lightbox-media-container">
<Show
when={props.type === "image"}
fallback={
<video
src={props.src}
class="dusk-lightbox-video"
controls
autoplay
onClick={(e) => e.stopPropagation()}
>
<track kind="captions" />
</video>
}
>
<img
src={props.src}
alt={props.alt || "media"}
class="dusk-lightbox-image"
style={{
transform: `translate(${translate().x}px, ${translate().y}px) scale(${scale()})`,
cursor: dragging() ? "grabbing" : "grab",
}}
draggable={false}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onClick={(e) => e.stopPropagation()}
/>
</Show>
</div>
</div>
</Portal>
</Show>
);
};
export default Lightbox;

View File

@ -65,7 +65,7 @@ const UserFooter: Component<UserFooterProps> = (props) => {
}); });
return ( return (
<div class="h-16 shrink-0 flex items-center gap-3 px-3 bg-black border-t border-white/10 relative"> <div class="h-[67px] shrink-0 flex items-center gap-3 px-3 bg-black border-t border-white/10 relative">
<Show when={user()}> <Show when={user()}>
<div <div
ref={triggerRef} ref={triggerRef}

View File

@ -18,6 +18,9 @@ import {
Headphones, Headphones,
HeadphoneOff, HeadphoneOff,
PhoneOff, PhoneOff,
Settings,
UserPlus,
LogOut,
} from "lucide-solid"; } from "lucide-solid";
import { import {
channels, channels,
@ -42,6 +45,8 @@ import {
} from "../../stores/voice"; } from "../../stores/voice";
import { identity } from "../../stores/identity"; import { identity } from "../../stores/identity";
import SidebarLayout from "../common/SidebarLayout"; import SidebarLayout from "../common/SidebarLayout";
import DropdownMenu from "../common/DropdownMenu";
import type { DropdownItem } from "../common/DropdownMenu";
import Avatar from "../common/Avatar"; import Avatar from "../common/Avatar";
import type { ChannelMeta } from "../../lib/types"; import type { ChannelMeta } from "../../lib/types";
@ -282,6 +287,33 @@ const ChannelList: Component = () => {
return all; 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) => { const handleChannelClick = (channel: ChannelMeta) => {
if (channel.kind === "Voice") { if (channel.kind === "Voice") {
// clicking a voice channel joins it (or switches to it) // clicking a voice channel joins it (or switches to it)
@ -350,7 +382,13 @@ const ChannelList: Component = () => {
const header = ( const header = (
<div class="h-15 border-b border-white/10 flex flex-col justify-end"> <div class="h-15 border-b border-white/10 flex flex-col justify-end">
<div class="h-12 flex items-center justify-between px-4"> <div
ref={headerRef}
class="h-12 flex items-center justify-between px-4 cursor-pointer hover:bg-white/5 transition-colors duration-200"
onClick={() => {
if (community()) setServerDropdownOpen((v) => !v);
}}
>
<Show <Show
when={community()} when={community()}
fallback={ fallback={
@ -361,13 +399,26 @@ const ChannelList: Component = () => {
{community()!.name} {community()!.name}
</span> </span>
</Show> </Show>
<button <Show when={community()}>
type="button" <div class="text-white/40 hover:text-white transition-colors duration-200">
class="text-white/40 hover:text-white transition-colors duration-200 cursor-pointer" <ChevronDown
> size={20}
<ChevronDown size={20} /> class="transition-transform duration-200"
</button> style={{
transform: serverDropdownOpen()
? "rotate(180deg)"
: "rotate(0deg)",
}}
/>
</div> </div>
</Show>
</div>
<DropdownMenu
items={serverDropdownItems()}
isOpen={serverDropdownOpen()}
onClose={() => setServerDropdownOpen(false)}
anchorRef={headerRef}
/>
</div> </div>
); );

View File

@ -282,18 +282,7 @@ const HomeView: Component = () => {
</Show> </Show>
</div> </div>
{/* status bar at the bottom */}
<div class="h-8 shrink-0 flex items-center justify-between px-6 border-t border-white/10">
<span class="text-[11px] font-mono text-white/30">
{nodeStatus() === "running"
? peerCount() > 0
? `connected - ${peerCount()} peers on network`
: "searching for peers..."
: nodeStatus() === "starting"
? "connecting to network..."
: "offline"}
</span>
</div>
</div> </div>
); );
}; };

File diff suppressed because it is too large Load Diff

View File

@ -50,18 +50,59 @@ function textNodeToMarkdown(node: JSONContent): string {
return text; return text;
} }
// known gif/image cdn domains that may not use file extensions in their urls
const GIF_CDN_HOSTS = [
"static.klipy.com",
"media.tenor.com",
"media1.tenor.com",
"c.tenor.com",
];
// check if a string is a standalone image/gif url (no other text) // check if a string is a standalone image/gif url (no other text)
export function isStandaloneImageUrl(text: string): boolean { export function isStandaloneImageUrl(text: string): boolean {
return /^https?:\/\/\S+\.(gif|png|jpg|jpeg|webp)(\?\S*)?$/i.test(text.trim()); const trimmed = text.trim();
// match common image file extensions
if (/^https?:\/\/\S+\.(gif|png|jpg|jpeg|webp)(\?\S*)?$/i.test(trimmed)) {
return true;
}
// match known gif cdn domains regardless of extension
try {
const url = new URL(trimmed);
return GIF_CDN_HOSTS.includes(url.hostname);
} catch {
return false;
}
}
// check if a string is a standalone video url
export function isStandaloneVideoUrl(text: string): boolean {
const trimmed = text.trim();
return /^https?:\/\/\S+\.(mp4|webm|mov|ogg)(\?\S*)?$/i.test(trimmed);
}
// determine what kind of standalone media a message contains, if any
export type MediaKind = "image" | "video" | null;
export function getStandaloneMediaKind(text: string): MediaKind {
if (isStandaloneImageUrl(text)) return "image";
if (isStandaloneVideoUrl(text)) return "video";
return null;
} }
// parse markdown-formatted text into safe html for display // parse markdown-formatted text into safe html for display
// only produces a limited set of elements - no script injection possible // only produces a limited set of elements - no script injection possible
export function renderMarkdown(text: string): string { export function renderMarkdown(text: string): string {
// standalone image url gets rendered as a full image // standalone image url gets rendered as a clickable image
if (isStandaloneImageUrl(text)) { if (isStandaloneImageUrl(text)) {
const url = escapeHtml(text.trim()); const url = escapeHtml(text.trim());
return `<img src="${url}" class="dusk-msg-image" alt="image" loading="lazy" />`; return `<img src="${url}" class="dusk-msg-image dusk-media-clickable" alt="image" loading="lazy" />`;
}
// standalone video url gets rendered as an inline video player
if (isStandaloneVideoUrl(text)) {
const url = escapeHtml(text.trim());
return `<video src="${url}" class="dusk-msg-video dusk-media-clickable" preload="metadata" loop muted autoplay playsinline><track kind="captions" /></video>`;
} }
// split by inline code spans to avoid parsing markdown inside code // split by inline code spans to avoid parsing markdown inside code

View File

@ -132,6 +132,54 @@ export async function reorderChannels(
return invoke("reorder_channels", { communityId, channelIds }); return invoke("reorder_channels", { communityId, channelIds });
} }
// -- community settings --
export async function updateCommunity(
communityId: string,
name: string,
description: string,
): Promise<CommunityMeta> {
return invoke("update_community", { communityId, name, description });
}
export async function updateChannel(
communityId: string,
channelId: string,
name: string,
topic: string,
): Promise<ChannelMeta> {
return invoke("update_channel", { communityId, channelId, name, topic });
}
export async function deleteChannel(
communityId: string,
channelId: string,
): Promise<void> {
return invoke("delete_channel", { communityId, channelId });
}
export async function deleteCategory(
communityId: string,
categoryId: string,
): Promise<void> {
return invoke("delete_category", { communityId, categoryId });
}
export async function setMemberRole(
communityId: string,
memberPeerId: string,
role: string,
): Promise<void> {
return invoke("set_member_role", { communityId, memberPeerId, role });
}
export async function transferOwnership(
communityId: string,
newOwnerPeerId: string,
): Promise<void> {
return invoke("transfer_ownership", { communityId, newOwnerPeerId });
}
// -- messages -- // -- messages --
export async function sendMessage( export async function sendMessage(

View File

@ -31,4 +31,32 @@ export async function reorderChannels(channelIds: string[]): Promise<void> {
} }
} }
export function removeChannel(channelId: string) {
setChannels((prev) => prev.filter((c) => c.id !== channelId));
// switch to the first remaining channel if the deleted one was active
if (activeChannelId() === channelId) {
const remaining = channels();
setActiveChannelId(remaining.length > 0 ? remaining[0].id : null);
}
}
export function removeCategory(categoryId: string) {
setCategories((prev) => prev.filter((c) => c.id !== categoryId));
// ungroup any channels that were in this category
setChannels((prev) =>
prev.map((c) =>
c.category_id === categoryId ? { ...c, category_id: null } : c,
),
);
}
export function updateChannelMeta(
channelId: string,
updates: Partial<ChannelMeta>,
) {
setChannels((prev) =>
prev.map((c) => (c.id === channelId ? { ...c, ...updates } : c)),
);
}
export { channels, categories, activeChannelId, setChannels, setCategories }; export { channels, categories, activeChannelId, setChannels, setCategories };

View File

@ -46,4 +46,13 @@ export async function leaveCommunity(communityId: string): Promise<void> {
removeCommunity(communityId); removeCommunity(communityId);
} }
export function updateCommunityMeta(
id: string,
updates: Partial<CommunityMeta>,
) {
setCommunities((prev) =>
prev.map((c) => (c.id === id ? { ...c, ...updates } : c)),
);
}
export { communities, activeCommunityId, setCommunities }; export { communities, activeCommunityId, setCommunities };

View File

@ -105,4 +105,10 @@ export function removeMember(peerId: string) {
}); });
} }
export function updateMemberRole(peerId: string, roles: string[]) {
setMembers((prev) =>
prev.map((m) => (m.peer_id === peerId ? { ...m, roles } : m)),
);
}
export { members, typingPeerIds, setMembers, onlinePeerIds }; export { members, typingPeerIds, setMembers, onlinePeerIds };

View File

@ -570,3 +570,109 @@ body {
object-fit: contain; object-fit: contain;
margin-top: 4px; margin-top: 4px;
} }
/* clickable media in messages */
.dusk-media-clickable {
cursor: pointer;
transition: opacity var(--transition-fast);
}
.dusk-media-clickable:hover {
opacity: 0.85;
}
/* inline video in messages */
.dusk-msg-video-wrapper {
white-space: normal;
}
.dusk-msg-video {
max-width: 400px;
max-height: 300px;
object-fit: contain;
margin-top: 4px;
}
/* ============================================================
lightbox overlay
============================================================ */
.dusk-lightbox-backdrop {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.92);
animation: dusk-lightbox-fade-in 150ms ease-out;
}
@keyframes dusk-lightbox-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.dusk-lightbox-toolbar {
position: fixed;
top: 16px;
right: 16px;
display: flex;
align-items: center;
gap: 4px;
z-index: 3001;
}
.dusk-lightbox-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
cursor: pointer;
transition:
color 200ms,
background 200ms;
}
.dusk-lightbox-btn:hover {
color: white;
background: rgba(255, 255, 255, 0.15);
}
.dusk-lightbox-divider {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.15);
margin: 0 4px;
}
.dusk-lightbox-media-container {
display: flex;
align-items: center;
justify-content: center;
max-width: 90vw;
max-height: 90vh;
}
.dusk-lightbox-image {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
transition: transform 100ms ease-out;
}
.dusk-lightbox-video {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
outline: none;
}