-
- Group Charter Manager
-
+
Scan the QR code to login with your W3DS identity
diff --git a/platforms/pictique-api/src/controllers/MessageController.ts b/platforms/pictique-api/src/controllers/MessageController.ts
index 9c4b152d..1806421c 100644
--- a/platforms/pictique-api/src/controllers/MessageController.ts
+++ b/platforms/pictique-api/src/controllers/MessageController.ts
@@ -22,10 +22,13 @@ export class MessageController {
...new Set([userId, ...(participantIds || [])]),
];
- // Check if a chat already exists with the same participants
- const existingChat = await this.chatService.findChatByParticipants(allParticipants);
- if (existingChat) {
- return res.status(200).json(existingChat);
+ // Only check for duplicates if it's a direct message (2 participants, no name)
+ // Allow multiple groups with the same members
+ if (allParticipants.length === 2 && !name) {
+ const existingChat = await this.chatService.findChatByParticipants(allParticipants);
+ if (existingChat) {
+ return res.status(200).json(existingChat);
+ }
}
const chat = await this.chatService.createChat(
diff --git a/platforms/pictique-api/src/controllers/WebhookController.ts b/platforms/pictique-api/src/controllers/WebhookController.ts
index 3ad81d9d..626a1802 100644
--- a/platforms/pictique-api/src/controllers/WebhookController.ts
+++ b/platforms/pictique-api/src/controllers/WebhookController.ts
@@ -6,6 +6,7 @@ import { CommentService } from "../services/CommentService";
import { Web3Adapter } from "../../../../infrastructure/web3-adapter/src";
import { User } from "database/entities/User";
import { Chat } from "database/entities/Chat";
+import { Message } from "database/entities/Message";
import { MessageService } from "../services/MessageService";
import { Post } from "database/entities/Post";
import axios from "axios";
@@ -247,6 +248,10 @@ export class WebhookController {
} else if (mapping.tableName === "messages") {
console.log("messages");
console.log(local.data);
+
+ // Check if this is a system message
+ const isSystemMessage = !local.data.sender || (typeof local.data.text === 'string' && local.data.text.startsWith('$$system-message$$'));
+
let sender: User | null = null;
if (
local.data.sender &&
@@ -264,10 +269,23 @@ export class WebhookController {
chat = await this.chatService.findById(chatId);
}
- if (!sender || !chat) {
- console.log(local.data);
- console.log("Missing sender or chat for message");
- return res.status(400).send();
+ // For system messages, we only need the chat, not the sender
+ if (isSystemMessage) {
+ if (!chat) {
+ console.log(local.data);
+ console.log("Missing chat for system message");
+ return res.status(400).send();
+ }
+
+ // System messages don't require a sender
+ sender = null;
+ } else {
+ // Regular messages need both sender and chat
+ if (!sender || !chat) {
+ console.log(local.data);
+ console.log("Missing sender or chat for regular message");
+ return res.status(400).send();
+ }
}
if (localId) {
@@ -276,17 +294,29 @@ export class WebhookController {
if (!message) return res.status(500).send();
message.text = local.data.text as string;
- message.sender = sender;
+ message.sender = sender || undefined;
message.chat = chat;
this.adapter.addToLockedIds(localId);
await this.messageService.messageRepository.save(message);
} else {
- const message = await this.chatService.sendMessage(
- chat.id,
- sender.id,
- local.data.text as string
- );
+ let message: Message;
+
+ if (isSystemMessage) {
+ // Create system message directly using MessageService
+ console.log("Creating system message");
+ message = await this.messageService.createSystemMessage(
+ chat.id,
+ local.data.text as string
+ );
+ } else {
+ // Create regular message using ChatService
+ message = await this.chatService.sendMessage(
+ chat.id,
+ sender!.id, // We know sender is not null for regular messages
+ local.data.text as string
+ );
+ }
this.adapter.addToLockedIds(message.id);
await this.adapter.mappingDb.storeMapping({
diff --git a/platforms/pictique-api/src/database/entities/Message.ts b/platforms/pictique-api/src/database/entities/Message.ts
index 0f67cdbf..a617166b 100644
--- a/platforms/pictique-api/src/database/entities/Message.ts
+++ b/platforms/pictique-api/src/database/entities/Message.ts
@@ -16,8 +16,8 @@ export class Message {
@PrimaryGeneratedColumn("uuid")
id!: string;
- @ManyToOne(() => User)
- sender!: User;
+ @ManyToOne(() => User, { nullable: true })
+ sender?: User; // Nullable for system messages
@Column("text")
text!: string;
@@ -28,6 +28,9 @@ export class Message {
@OneToMany(() => MessageReadStatus, (status) => status.message)
readStatuses!: MessageReadStatus[];
+ @Column({ default: false })
+ isSystemMessage!: boolean; // Flag to identify system messages
+
@CreateDateColumn()
createdAt!: Date;
diff --git a/platforms/pictique-api/src/database/migrations/1755274671697-migration.ts b/platforms/pictique-api/src/database/migrations/1755274671697-migration.ts
new file mode 100644
index 00000000..68d0ae6c
--- /dev/null
+++ b/platforms/pictique-api/src/database/migrations/1755274671697-migration.ts
@@ -0,0 +1,14 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class Migration1755274671697 implements MigrationInterface {
+ name = 'Migration1755274671697'
+
+ public async up(queryRunner: QueryRunner): Promise
{
+ await queryRunner.query(`ALTER TABLE "messages" ADD "isSystemMessage" boolean NOT NULL DEFAULT false`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "messages" DROP COLUMN "isSystemMessage"`);
+ }
+
+}
diff --git a/platforms/pictique-api/src/services/ChatService.ts b/platforms/pictique-api/src/services/ChatService.ts
index ab548bee..2111ba8c 100644
--- a/platforms/pictique-api/src/services/ChatService.ts
+++ b/platforms/pictique-api/src/services/ChatService.ts
@@ -25,6 +25,11 @@ export class ChatService {
}
// Chat CRUD Operations
+ /**
+ * Find a chat with exactly the same participants.
+ * Note: This is primarily used to prevent duplicate direct messages (DMs).
+ * Groups with the same members are allowed to be duplicated.
+ */
async findChatByParticipants(participantIds: string[]): Promise {
if (participantIds.length === 0) {
return null;
@@ -216,10 +221,12 @@ export class ChatService {
}
// First get all message IDs for this chat that were sent by other users
+ // Exclude system messages (no sender) and messages sent by the current user
const messageIds = await this.messageRepository
.createQueryBuilder("message")
.select("message.id")
.where("message.chat.id = :chatId", { chatId })
+ .andWhere("message.sender IS NOT NULL") // Exclude system messages
.andWhere("message.sender.id != :userId", { userId }) // Only messages not sent by the user
.getMany();
@@ -250,6 +257,11 @@ export class ChatService {
throw new Error("Message not found");
}
+ // System messages cannot be deleted by users
+ if (!message.sender) {
+ throw new Error("Cannot delete system messages");
+ }
+
if (message.sender.id !== userId) {
throw new Error("Unauthorized to delete this message");
}
diff --git a/platforms/pictique-api/src/services/MessageService.ts b/platforms/pictique-api/src/services/MessageService.ts
index 5d385439..a2732876 100644
--- a/platforms/pictique-api/src/services/MessageService.ts
+++ b/platforms/pictique-api/src/services/MessageService.ts
@@ -8,14 +8,19 @@ export class MessageService {
return await this.messageRepository.findOneBy({ id });
}
- async createMessage(senderId: string, chatId: string, text: string): Promise {
+ async createMessage(senderId: string | null, chatId: string, text: string, isSystemMessage: boolean = false): Promise {
const message = this.messageRepository.create({
- sender: { id: senderId },
+ sender: senderId ? { id: senderId } : undefined,
chat: { id: chatId },
text,
+ isSystemMessage,
isArchived: false
});
return await this.messageRepository.save(message);
}
+
+ async createSystemMessage(chatId: string, text: string): Promise {
+ return this.createMessage(null, chatId, text, true);
+ }
}
\ No newline at end of file
diff --git a/platforms/pictique/src/lib/fragments/ChatMessage/ChatMessage.svelte b/platforms/pictique/src/lib/fragments/ChatMessage/ChatMessage.svelte
index 5d13788d..fda9a810 100644
--- a/platforms/pictique/src/lib/fragments/ChatMessage/ChatMessage.svelte
+++ b/platforms/pictique/src/lib/fragments/ChatMessage/ChatMessage.svelte
@@ -4,12 +4,18 @@
import type { HTMLAttributes } from 'svelte/elements';
interface IChatMessageProps extends HTMLAttributes {
- userImgSrc: string;
+ userImgSrc?: string;
message: string;
time: string;
isOwn: boolean;
isHeadNeeded?: boolean;
isTimestampNeeded?: boolean;
+ sender?: {
+ id: string;
+ name: string;
+ handle: string;
+ avatarUrl: string;
+ } | null;
}
let {
@@ -19,62 +25,95 @@
isOwn,
isHeadNeeded = true,
isTimestampNeeded = true,
+ sender = null,
...restProps
}: IChatMessageProps = $props();
+
+ // Check if this is a system message
+ const isSystemMessage = $derived(!sender && message.startsWith('$$system-message$$'));
+ // Remove the prefix for display
+ const displayText = $derived(
+ isSystemMessage ? message.replace('$$system-message$$', '').trim() : message
+ );
-
-
- {#if isHeadNeeded}
-
- {/if}
+{#if isSystemMessage}
+
+
+
+
+ {#if isTimestampNeeded}
+
+ {time}
+
+ {/if}
+
-
-
-
+{:else}
+
+
+
{#if isHeadNeeded}
-
+
{/if}
-
-
- {message}
-
- {#if isTimestampNeeded}
-
+
- {time}
-
- {/if}
+ {#if isHeadNeeded}
+
+ {/if}
+
+
+
+
+ {#if isTimestampNeeded}
+
+ {time}
+
+ {/if}
+
-
+{/if}
diff --git a/platforms/pictique/src/lib/stores/users.ts b/platforms/pictique/src/lib/stores/users.ts
index 5ca291ca..b1ef1738 100644
--- a/platforms/pictique/src/lib/stores/users.ts
+++ b/platforms/pictique/src/lib/stores/users.ts
@@ -23,8 +23,17 @@ export const searchUsers = async (query: string) => {
try {
isSearching.set(true);
searchError.set(null);
- const response = await apiClient.get(`/api/users/search?q=${encodeURIComponent(query)}`);
- searchResults.set(response.data);
+ // Convert query to lowercase for case-insensitive search
+ const lowercaseQuery = query.toLowerCase();
+ const response = await apiClient.get(`/api/users/search?q=${encodeURIComponent(lowercaseQuery)}`);
+
+ // Filter results to only show users whose names match the query
+ // This ensures we only search by name, not username or ename
+ const filteredResults = response.data.filter((user: any) =>
+ user.name && user.name.toLowerCase().includes(lowercaseQuery)
+ );
+
+ searchResults.set(filteredResults);
} catch (err) {
searchError.set(err instanceof Error ? err.message : 'Failed to search users');
searchResults.set([]);
diff --git a/platforms/pictique/src/lib/types.ts b/platforms/pictique/src/lib/types.ts
index 4195a816..b48eaafd 100644
--- a/platforms/pictique/src/lib/types.ts
+++ b/platforms/pictique/src/lib/types.ts
@@ -83,6 +83,7 @@ export type GroupInfo = {
export type Chat = {
id: string;
+ name?: string; // Add the name property for group chats
avatar: string;
handle: string;
unread: boolean;
@@ -109,3 +110,17 @@ export type MessageType = {
name: string;
username: string;
};
+
+export interface Message {
+ id: string;
+ text: string;
+ sender?: {
+ id: string;
+ name: string;
+ handle: string;
+ avatarUrl: string;
+ };
+ isSystemMessage: boolean;
+ createdAt: string;
+ updatedAt: string;
+}
diff --git a/platforms/pictique/src/lib/utils/mobile-detection.ts b/platforms/pictique/src/lib/utils/mobile-detection.ts
new file mode 100644
index 00000000..d381195b
--- /dev/null
+++ b/platforms/pictique/src/lib/utils/mobile-detection.ts
@@ -0,0 +1,10 @@
+export function isMobileDevice(): boolean {
+ if (typeof window === 'undefined') return false;
+
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
+ (window.innerWidth <= 768);
+}
+
+export function getDeepLinkUrl(qrData: string): string {
+ return qrData;
+}
\ No newline at end of file
diff --git a/platforms/pictique/src/routes/(auth)/auth/+page.svelte b/platforms/pictique/src/routes/(auth)/auth/+page.svelte
index ad533b5c..89f6784b 100644
--- a/platforms/pictique/src/routes/(auth)/auth/+page.svelte
+++ b/platforms/pictique/src/routes/(auth)/auth/+page.svelte
@@ -7,6 +7,7 @@
import { onMount } from 'svelte';
import { onDestroy } from 'svelte';
import { qrcode } from 'svelte-qrcode-action';
+ import { isMobileDevice, getDeepLinkUrl } from '$lib/utils/mobile-detection';
let qrData: string;
let isMobile = false;
@@ -57,21 +58,27 @@
+
+ {#if isMobileDevice()}
+ Login with your eID Wallet
+ {:else}
+ Scan the QR code using your eID App to login
+ {/if}
+
{#if qrData}
- {#if isMobile}
-
- Click the button below to login using your eID App
-
-
- Login with eID Wallet
-
+ {#if isMobileDevice()}
+
{:else}
-
- Scan the QR code using your eID App to login
-
{/each}
import { goto } from '$app/navigation';
import { Message } from '$lib/fragments';
- import Group from '$lib/fragments/Group/Group.svelte';
import { isSearching, searchError, searchResults, searchUsers } from '$lib/stores/users';
- import type { Chat, GroupInfo, MessageType } from '$lib/types';
+ import type { Chat, MessageType } from '$lib/types';
import { Avatar, Button, Input } from '$lib/ui';
import { clickOutside } from '$lib/utils';
import { apiClient } from '$lib/utils/axios';
@@ -11,50 +10,114 @@
import { heading } from '../../store';
let messages = $state([]);
- let groups: GroupInfo[] = $state([]);
let allMembers = $state[]>([]);
let selectedMembers = $state([]);
+ let selectedMembersData = $state([]);
let currentUserId = '';
+ let profile = $state(null);
let openNewChatModal = $state(false);
+ let openNewGroupModal = $state(false);
let searchValue = $state('');
+ let groupName = $state('');
let debounceTimer: NodeJS.Timeout;
async function loadMessages() {
- const { data } = await apiClient.get<{ chats: Chat[] }>('/api/chats');
- const { data: userData } = await apiClient.get('/api/users');
- currentUserId = userData.id;
-
- messages = data.chats.map((c) => {
- const members = c.participants.filter((u) => u.id !== userData.id);
- const memberNames = members.map((m) => m.name ?? m.handle ?? m.ename);
- const avatar =
- members.length > 1
- ? 'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/icons/people-fill.svg'
- : members[0].avatarUrl;
- return {
- id: c.id,
- avatar,
- username: c.handle ?? memberNames.join(', '),
- unread: c.latestMessage ? c.latestMessage.isRead : false,
- text: c.latestMessage?.text ?? 'No message yet',
- handle: c.handle ?? memberNames.join(', '),
- name: c.handle ?? memberNames.join(', ')
- };
- });
+ try {
+ const { data } = await apiClient.get<{ chats: Chat[] }>('/api/chats');
+ const { data: userData } = await apiClient.get('/api/users');
+ currentUserId = userData.id;
+
+ console.log('Raw chat data from API:', data.chats);
+
+ // Show all chats (direct messages and groups) in one unified list
+ messages = data.chats.map((c) => {
+ const members = c.participants.filter((u) => u.id !== userData.id);
+ const memberNames = members.map((m) => m.name ?? m.handle ?? m.ename);
+ const isGroup = members.length > 1;
+
+ // Use group avatar for groups, user avatar for direct messages
+ const avatar = isGroup
+ ? '/images/group.png'
+ : members[0]?.avatarUrl ||
+ 'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/icons/people-fill.svg';
+
+ // For groups, prioritize the group name, fallback to member names
+ // For direct messages, use the other person's name
+ const displayName = isGroup
+ ? c.name || memberNames.join(', ') // Group name first, then member names
+ : c.name || members[0]?.name || members[0]?.handle || 'Unknown User';
+
+ return {
+ id: c.id,
+ avatar,
+ username: displayName,
+ unread: c.latestMessage ? !c.latestMessage.isRead : false,
+ text: c.latestMessage?.text ?? 'No message yet',
+ handle: displayName,
+ name: displayName
+ };
+ });
+ } catch (error) {
+ console.error('Failed to load messages:', error);
+ }
}
onMount(async () => {
- await loadMessages();
+ try {
+ await loadMessages();
+
+ // Get current user first
+ const { data: userData } = await apiClient.get('/api/users');
+ currentUserId = userData.id;
+ profile = userData; // Set profile data
+
+ // Load all members and pre-select current user
+ const memberRes = await apiClient.get('/api/members');
+ allMembers = memberRes.data;
+
+ // Pre-select current user for group creation
+ selectedMembers = [currentUserId];
+
+ // Store current user in allMembers if not already there
+ if (!allMembers.find((m) => m.id === currentUserId)) {
+ allMembers.push({
+ id: currentUserId,
+ name: userData.name || userData.handle,
+ handle: userData.handle,
+ avatarUrl: userData.avatarUrl || '/images/default-avatar.png'
+ });
+ }
- const memberRes = await apiClient.get('/api/members');
- allMembers = memberRes.data;
+ // Initialize selectedMembersData with current user
+ selectedMembersData = [
+ {
+ id: currentUserId,
+ name: userData.name || userData.handle,
+ handle: userData.handle,
+ avatarUrl: userData.avatarUrl || '/images/default-avatar.png'
+ }
+ ];
+ } catch (error) {
+ console.error('Failed to initialize messages page:', error);
+ }
});
- function toggleMemberSelection(id: string) {
+ function toggleMemberSelection(id: string): void {
if (selectedMembers.includes(id)) {
+ // Remove from selected members
selectedMembers = selectedMembers.filter((m) => m !== id);
+ selectedMembersData = selectedMembersData.filter((m) => m.id !== id);
} else {
+ // Add to selected members
selectedMembers = [...selectedMembers, id];
+
+ // Find the member data from search results or allMembers
+ const memberData =
+ $searchResults.find((m) => m.id === id) || allMembers.find((m) => m.id === id);
+
+ if (memberData) {
+ selectedMembersData = [...selectedMembersData, memberData];
+ }
}
}
@@ -62,7 +125,13 @@
searchValue = value;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
- searchUsers(value);
+ // Only search if there's a value, otherwise clear results
+ if (value.trim()) {
+ searchUsers(value);
+ } else {
+ // Clear search results when input is empty, but keep selected members
+ searchResults.set([]);
+ }
}, 300);
}
@@ -71,31 +140,69 @@
try {
if (selectedMembers.length === 1) {
- await apiClient.post('/api/chats/', {
+ // Create direct message
+ await apiClient.post('/api/chats', {
name: allMembers.find((m) => m.id === selectedMembers[0])?.name ?? 'New Chat',
participantIds: [selectedMembers[0]]
});
- await loadMessages(); // 🛠️ Refresh to include the new direct message
} else {
- const groupMembers = allMembers.filter((m) => selectedMembers.includes(m.id));
+ // Create group chat
+ const groupMembers = allMembers.filter((m) => m.id === selectedMembers[0]);
const groupName = groupMembers.map((m) => m.name ?? m.handle ?? m.ename).join(', ');
- groups = [
- ...groups,
- {
- id: Math.random().toString(36).slice(2),
- name: groupName,
- avatar: '/images/group.png'
- }
- ];
+
+ // Create group chat via API
+ await apiClient.post('/api/chats', {
+ name: groupName,
+ participantIds: selectedMembers,
+ isGroup: true
+ });
+ }
+
+ // Navigate to the new chat instead of hard refresh
+ if (selectedMembers.length === 1) {
+ // For direct messages, we need to find the chat ID
+ // For now, redirect to messages and let the user click on the new chat
+ goto('/messages');
+ } else {
+ // For group chats, redirect to messages
+ goto('/messages');
}
} catch (err) {
console.error('Failed to create chat:', err);
+ alert('Failed to create chat. Please try again.');
} finally {
openNewChatModal = false;
selectedMembers = [];
searchValue = '';
}
}
+
+ async function createGroup() {
+ if (selectedMembers.length === 0 || !groupName.trim()) return;
+
+ try {
+ // Use the chats endpoint for group creation
+ await apiClient.post('/api/chats', {
+ name: groupName.trim(),
+ participantIds: selectedMembers
+ });
+
+ // Close modal and reset form
+ openNewGroupModal = false;
+ groupName = '';
+ selectedMembers = [];
+ selectedMembersData = [];
+ searchValue = '';
+
+ // Navigate to messages and refresh the feed
+ goto('/messages');
+ // Refresh the messages to show the newly created group
+ await loadMessages();
+ } catch (err) {
+ console.error('Failed to create group:', err);
+ alert('Failed to create group. Please try again.');
+ }
+ }
@@ -104,10 +211,10 @@
variant="secondary"
size="sm"
callback={() => {
- openNewChatModal = true;
+ openNewGroupModal = true;
}}
>
- New Chat
+ New Group
@@ -127,69 +234,279 @@
{/each}
{/if}
- {#if groups.length > 0}
-
Groups
- {#each groups as group}
-
goto(`/group/${group.id}`)}
- />
- {/each}
- {:else if messages.length === 0}
+ {#if messages.length === 0}
You don't have any messages yet. Start a Direct Message by searching a name.
{/if}
-