Compare commits
14 Commits
6157d7e27d
...
9c1d3bc04c
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c1d3bc04c | |||
| 8df587f9e2 | |||
| 3796bc2953 | |||
| 2278dfa714 | |||
| 1a5c69fcfb | |||
| a671ae392d | |||
| 4c58d175a0 | |||
| bc655f0e06 | |||
| 22bcf01fa8 | |||
| bf8a3386d7 | |||
| d182231448 | |||
| 0a32ae77cd | |||
| 98c95483d8 | |||
| bcde1da84f |
@@ -53,6 +53,9 @@ OPENAI_API_KEY=replace-this
|
||||
STIRLING_API_KEY=replace-this
|
||||
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
|
||||
|
||||
# Interner Prometheus Node Exporter für die Admin-Systemstatusseite.
|
||||
NODE_EXPORTER_URL=http://node-exporter:9100
|
||||
|
||||
# Optionaler Erststart-Bootstrap. Wenn gesetzt, werden Admin, Mandant,
|
||||
# Admin-Rolle und Grunddaten beim Backend-Start idempotent angelegt.
|
||||
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
||||
|
||||
@@ -17,6 +17,7 @@ type MatrixRoomEvent = {
|
||||
sender: string
|
||||
origin_server_ts: number
|
||||
type: string
|
||||
redacts?: string
|
||||
content?: {
|
||||
body?: string
|
||||
msgtype?: string
|
||||
@@ -25,6 +26,18 @@ type MatrixRoomEvent = {
|
||||
mimetype?: string
|
||||
size?: number
|
||||
}
|
||||
"m.new_content"?: {
|
||||
body?: string
|
||||
msgtype?: string
|
||||
}
|
||||
"m.relates_to"?: {
|
||||
event_id?: string
|
||||
key?: string
|
||||
rel_type?: string
|
||||
"m.in_reply_to"?: {
|
||||
event_id?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +48,33 @@ type MatrixJoinedMembersResponse = {
|
||||
}>
|
||||
}
|
||||
|
||||
type MatrixRoomSearchResponse = {
|
||||
search_categories?: {
|
||||
room_events?: {
|
||||
count?: number
|
||||
results?: Array<{
|
||||
result?: MatrixRoomEvent & {
|
||||
room_id?: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type MatrixSyncResponse = {
|
||||
next_batch?: string
|
||||
rooms?: {
|
||||
join?: Record<string, {
|
||||
timeline?: {
|
||||
events?: MatrixRoomEvent[]
|
||||
}
|
||||
state?: {
|
||||
events?: MatrixRoomEvent[]
|
||||
}
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
type MatrixUserSession = {
|
||||
accessToken: string
|
||||
matrixUserId: string
|
||||
@@ -71,6 +111,10 @@ type MatrixAttachmentInput = {
|
||||
size: number
|
||||
}
|
||||
|
||||
type MatrixMessageOptions = {
|
||||
replyToEventId?: string
|
||||
}
|
||||
|
||||
type MatrixCachedValue<T = any> = {
|
||||
exists: true
|
||||
cachedUntil: number
|
||||
@@ -1158,27 +1202,72 @@ export function matrixService(server: FastifyInstance) {
|
||||
session.accessToken
|
||||
)
|
||||
|
||||
const replacementByEventId = new Map<string, MatrixRoomEvent>()
|
||||
const reactionsByEventId = new Map<string, Map<string, { key: string; count: number; own: boolean }>>()
|
||||
|
||||
for (const event of response.chunk) {
|
||||
const relation = event.content?.["m.relates_to"]
|
||||
|
||||
if (
|
||||
event.type === "m.room.message" &&
|
||||
relation?.rel_type === "m.replace" &&
|
||||
relation.event_id
|
||||
) {
|
||||
replacementByEventId.set(relation.event_id, event)
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "m.reaction" &&
|
||||
relation?.rel_type === "m.annotation" &&
|
||||
relation.event_id &&
|
||||
relation.key
|
||||
) {
|
||||
const eventReactions = reactionsByEventId.get(relation.event_id) || new Map()
|
||||
const reaction = eventReactions.get(relation.key) || {
|
||||
key: relation.key,
|
||||
count: 0,
|
||||
own: false,
|
||||
}
|
||||
|
||||
reaction.count += 1
|
||||
reaction.own = reaction.own || event.sender === session.matrixUserId
|
||||
eventReactions.set(relation.key, reaction)
|
||||
reactionsByEventId.set(relation.event_id, eventReactions)
|
||||
}
|
||||
}
|
||||
|
||||
const messages = response.chunk
|
||||
.filter((event) =>
|
||||
event.type === "m.room.message" &&
|
||||
["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") &&
|
||||
event.content?.["m.relates_to"]?.rel_type !== "m.replace"
|
||||
)
|
||||
.map((event) => {
|
||||
const replacement = replacementByEventId.get(event.event_id)
|
||||
const content = replacement?.content?.["m.new_content"] || replacement?.content || event.content
|
||||
|
||||
return {
|
||||
id: event.event_id,
|
||||
sender: event.sender,
|
||||
senderDisplayName: members.joined[event.sender]?.display_name || event.sender,
|
||||
body: content?.body || "",
|
||||
attachment: attachmentFromEvent({ ...event, content }),
|
||||
timestamp: replacement?.origin_server_ts || event.origin_server_ts,
|
||||
own: event.sender === session.matrixUserId,
|
||||
edited: Boolean(replacement),
|
||||
replyToEventId: event.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id || null,
|
||||
reactions: Array.from(reactionsByEventId.get(event.event_id)?.values() || []),
|
||||
}
|
||||
})
|
||||
.reverse()
|
||||
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
alias: room.alias,
|
||||
key: room.key,
|
||||
name: room.name,
|
||||
matrixUserId: session.matrixUserId,
|
||||
messages: response.chunk
|
||||
.filter((event) =>
|
||||
event.type === "m.room.message" &&
|
||||
["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "")
|
||||
)
|
||||
.map((event) => ({
|
||||
id: event.event_id,
|
||||
sender: event.sender,
|
||||
senderDisplayName: members.joined[event.sender]?.display_name || event.sender,
|
||||
body: event.content?.body || "",
|
||||
attachment: attachmentFromEvent(event),
|
||||
timestamp: event.origin_server_ts,
|
||||
own: event.sender === session.matrixUserId,
|
||||
}))
|
||||
.reverse(),
|
||||
messages,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1212,11 +1301,223 @@ export function matrixService(server: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
const searchTenantRoomMessages = async (
|
||||
userId: string,
|
||||
tenantId: number | null,
|
||||
options: MatrixTenantRoomOptions = {},
|
||||
query: string
|
||||
) => {
|
||||
const searchTerm = query.trim()
|
||||
|
||||
if (searchTerm.length < 2) {
|
||||
return {
|
||||
roomId: "",
|
||||
alias: "",
|
||||
key: options.key || "allgemein",
|
||||
name: options.name || options.key || "Chat",
|
||||
count: 0,
|
||||
results: [],
|
||||
}
|
||||
}
|
||||
|
||||
const room = await provisionTenantRoom(userId, tenantId, options)
|
||||
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
|
||||
roomId: room.roomId,
|
||||
alias: room.alias,
|
||||
})
|
||||
const [response, members] = await Promise.all([
|
||||
requestMatrixJson<MatrixRoomSearchResponse>(
|
||||
"/_matrix/client/v3/search",
|
||||
session.accessToken,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
search_categories: {
|
||||
room_events: {
|
||||
search_term: searchTerm,
|
||||
keys: ["content.body"],
|
||||
order_by: "recent",
|
||||
filter: {
|
||||
limit: 25,
|
||||
rooms: [room.roomId],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
),
|
||||
requestMatrixJson<MatrixJoinedMembersResponse>(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`,
|
||||
session.accessToken
|
||||
),
|
||||
])
|
||||
|
||||
const roomEvents = response.search_categories?.room_events
|
||||
const results = (roomEvents?.results || [])
|
||||
.map((item) => item.result)
|
||||
.filter((event): event is MatrixRoomEvent & { room_id?: string } =>
|
||||
Boolean(
|
||||
event?.event_id &&
|
||||
event.type === "m.room.message" &&
|
||||
["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "")
|
||||
)
|
||||
)
|
||||
.map((event) => ({
|
||||
id: event.event_id,
|
||||
roomId: event.room_id || room.roomId,
|
||||
key: room.key,
|
||||
sender: event.sender,
|
||||
senderDisplayName: members.joined[event.sender]?.display_name || event.sender,
|
||||
body: event.content?.body || "",
|
||||
attachment: attachmentFromEvent(event),
|
||||
timestamp: event.origin_server_ts,
|
||||
own: event.sender === session.matrixUserId,
|
||||
}))
|
||||
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
alias: room.alias,
|
||||
key: room.key,
|
||||
name: room.name,
|
||||
count: roomEvents?.count || results.length,
|
||||
results,
|
||||
}
|
||||
}
|
||||
|
||||
const syncTenantRoomEvents = async (
|
||||
userId: string,
|
||||
tenantId: number | null,
|
||||
options: MatrixTenantRoomOptions = {},
|
||||
since?: string,
|
||||
initial = false
|
||||
) => {
|
||||
const room = await provisionTenantRoom(userId, tenantId, options)
|
||||
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
|
||||
roomId: room.roomId,
|
||||
alias: room.alias,
|
||||
})
|
||||
const filter = {
|
||||
room: {
|
||||
rooms: [room.roomId],
|
||||
timeline: {
|
||||
limit: 30,
|
||||
},
|
||||
},
|
||||
presence: {
|
||||
types: [],
|
||||
},
|
||||
account_data: {
|
||||
types: [],
|
||||
},
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
timeout: since && !initial ? "25000" : "0",
|
||||
filter: JSON.stringify(filter),
|
||||
})
|
||||
|
||||
if (since) params.set("since", since)
|
||||
|
||||
const response = await requestMatrixJson<MatrixSyncResponse>(
|
||||
`/_matrix/client/v3/sync?${params.toString()}`,
|
||||
session.accessToken
|
||||
)
|
||||
const joinedRoom = response.rooms?.join?.[room.roomId]
|
||||
const timelineEvents = joinedRoom?.timeline?.events || []
|
||||
const stateEvents = joinedRoom?.state?.events || []
|
||||
|
||||
if (initial) {
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
alias: room.alias,
|
||||
key: room.key,
|
||||
name: room.name,
|
||||
nextBatch: response.next_batch || since || "",
|
||||
messages: [],
|
||||
replacements: [],
|
||||
reactions: [],
|
||||
redactions: [],
|
||||
membersChanged: false,
|
||||
}
|
||||
}
|
||||
|
||||
const messages = timelineEvents
|
||||
.filter((event) =>
|
||||
event.type === "m.room.message" &&
|
||||
["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") &&
|
||||
event.content?.["m.relates_to"]?.rel_type !== "m.replace"
|
||||
)
|
||||
.map((event) => ({
|
||||
id: event.event_id,
|
||||
sender: event.sender,
|
||||
senderDisplayName: event.sender,
|
||||
body: event.content?.body || "",
|
||||
attachment: attachmentFromEvent(event),
|
||||
timestamp: event.origin_server_ts,
|
||||
own: event.sender === session.matrixUserId,
|
||||
replyToEventId: event.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id || null,
|
||||
reactions: [],
|
||||
}))
|
||||
|
||||
const replacements = timelineEvents
|
||||
.filter((event) =>
|
||||
event.type === "m.room.message" &&
|
||||
event.content?.["m.relates_to"]?.rel_type === "m.replace" &&
|
||||
Boolean(event.content?.["m.relates_to"]?.event_id)
|
||||
)
|
||||
.map((event) => ({
|
||||
id: event.event_id,
|
||||
targetEventId: event.content?.["m.relates_to"]?.event_id,
|
||||
body: event.content?.["m.new_content"]?.body || event.content?.body || "",
|
||||
timestamp: event.origin_server_ts,
|
||||
sender: event.sender,
|
||||
own: event.sender === session.matrixUserId,
|
||||
}))
|
||||
|
||||
const reactions = timelineEvents
|
||||
.filter((event) =>
|
||||
event.type === "m.reaction" &&
|
||||
event.content?.["m.relates_to"]?.rel_type === "m.annotation" &&
|
||||
Boolean(event.content?.["m.relates_to"]?.event_id) &&
|
||||
Boolean(event.content?.["m.relates_to"]?.key)
|
||||
)
|
||||
.map((event) => ({
|
||||
id: event.event_id,
|
||||
targetEventId: event.content?.["m.relates_to"]?.event_id,
|
||||
key: event.content?.["m.relates_to"]?.key,
|
||||
sender: event.sender,
|
||||
own: event.sender === session.matrixUserId,
|
||||
}))
|
||||
|
||||
const redactions = timelineEvents
|
||||
.filter((event) => event.type === "m.room.redaction" && Boolean(event.redacts))
|
||||
.map((event) => ({
|
||||
id: event.event_id,
|
||||
targetEventId: event.redacts,
|
||||
sender: event.sender,
|
||||
timestamp: event.origin_server_ts,
|
||||
}))
|
||||
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
alias: room.alias,
|
||||
key: room.key,
|
||||
name: room.name,
|
||||
nextBatch: response.next_batch || since || "",
|
||||
messages,
|
||||
replacements,
|
||||
reactions,
|
||||
redactions,
|
||||
membersChanged: [...timelineEvents, ...stateEvents].some((event) => event.type === "m.room.member"),
|
||||
}
|
||||
}
|
||||
|
||||
const sendTenantRoomMessage = async (
|
||||
userId: string,
|
||||
tenantId: number | null,
|
||||
options: MatrixTenantRoomOptions = {},
|
||||
text: string
|
||||
text: string,
|
||||
messageOptions: MatrixMessageOptions = {}
|
||||
) => {
|
||||
const message = text.trim()
|
||||
|
||||
@@ -1235,16 +1536,26 @@ export function matrixService(server: FastifyInstance) {
|
||||
})
|
||||
const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}`
|
||||
|
||||
const content: Record<string, any> = {
|
||||
msgtype: "m.text",
|
||||
body: message,
|
||||
}
|
||||
|
||||
if (messageOptions.replyToEventId) {
|
||||
content["m.relates_to"] = {
|
||||
"m.in_reply_to": {
|
||||
event_id: messageOptions.replyToEventId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const response = await requestMatrixJson<{ event_id: string }>(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
|
||||
session.accessToken,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
msgtype: "m.text",
|
||||
body: message,
|
||||
}),
|
||||
body: JSON.stringify(content),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1258,9 +1569,163 @@ export function matrixService(server: FastifyInstance) {
|
||||
roomId: room.roomId,
|
||||
alias: room.alias,
|
||||
key: room.key,
|
||||
replyToEventId: messageOptions.replyToEventId || null,
|
||||
reactions: [],
|
||||
}
|
||||
}
|
||||
|
||||
const sendTenantRoomReaction = async (
|
||||
userId: string,
|
||||
tenantId: number | null,
|
||||
options: MatrixTenantRoomOptions = {},
|
||||
eventId: string,
|
||||
key: string
|
||||
) => {
|
||||
const reactionKey = key.trim()
|
||||
if (!eventId || !reactionKey) {
|
||||
throw Object.assign(
|
||||
new Error("Reaction target and key are required"),
|
||||
{ statusCode: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const room = await provisionTenantRoom(userId, tenantId, options)
|
||||
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
|
||||
roomId: room.roomId,
|
||||
alias: room.alias,
|
||||
})
|
||||
const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}`
|
||||
await requestMatrixJson<{ event_id: string }>(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.reaction/${encodeURIComponent(txnId)}`,
|
||||
session.accessToken,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
"m.relates_to": {
|
||||
rel_type: "m.annotation",
|
||||
event_id: eventId,
|
||||
key: reactionKey,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
return { success: true, eventId, key: reactionKey }
|
||||
}
|
||||
|
||||
const editTenantRoomMessage = async (
|
||||
userId: string,
|
||||
tenantId: number | null,
|
||||
options: MatrixTenantRoomOptions = {},
|
||||
eventId: string,
|
||||
text: string
|
||||
) => {
|
||||
const message = text.trim()
|
||||
if (!eventId || !message) {
|
||||
throw Object.assign(
|
||||
new Error("Nachricht und Zielnachricht sind erforderlich"),
|
||||
{ statusCode: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const room = await provisionTenantRoom(userId, tenantId, options)
|
||||
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
|
||||
roomId: room.roomId,
|
||||
alias: room.alias,
|
||||
})
|
||||
const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}`
|
||||
const response = await requestMatrixJson<{ event_id: string }>(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
|
||||
session.accessToken,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
msgtype: "m.text",
|
||||
body: `* ${message}`,
|
||||
"m.new_content": {
|
||||
msgtype: "m.text",
|
||||
body: message,
|
||||
},
|
||||
"m.relates_to": {
|
||||
rel_type: "m.replace",
|
||||
event_id: eventId,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
id: response.event_id,
|
||||
targetEventId: eventId,
|
||||
body: message,
|
||||
timestamp: Date.now(),
|
||||
own: true,
|
||||
}
|
||||
}
|
||||
|
||||
const redactTenantRoomMessage = async (
|
||||
userId: string,
|
||||
tenantId: number | null,
|
||||
options: MatrixTenantRoomOptions = {},
|
||||
eventId: string
|
||||
) => {
|
||||
if (!eventId) {
|
||||
throw Object.assign(
|
||||
new Error("Zielnachricht ist erforderlich"),
|
||||
{ statusCode: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const room = await provisionTenantRoom(userId, tenantId, options)
|
||||
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
|
||||
roomId: room.roomId,
|
||||
alias: room.alias,
|
||||
})
|
||||
const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}`
|
||||
await requestMatrixJson(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/redact/${encodeURIComponent(eventId)}/${encodeURIComponent(txnId)}`,
|
||||
session.accessToken,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
reason: "Nachricht in FEDEO gelöscht",
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
return { success: true, eventId }
|
||||
}
|
||||
|
||||
const markTenantRoomRead = async (
|
||||
userId: string,
|
||||
tenantId: number | null,
|
||||
options: MatrixTenantRoomOptions = {},
|
||||
eventId: string
|
||||
) => {
|
||||
if (!eventId) return { success: true, skipped: true }
|
||||
|
||||
const room = await provisionTenantRoom(userId, tenantId, options)
|
||||
const session = await ensureCurrentUserJoinedRoom(userId, tenantId, {
|
||||
roomId: room.roomId,
|
||||
alias: room.alias,
|
||||
})
|
||||
|
||||
await requestMatrixJson(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/receipt/m.read/${encodeURIComponent(eventId)}`,
|
||||
session.accessToken,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
)
|
||||
|
||||
return { success: true, eventId }
|
||||
}
|
||||
|
||||
const sendTenantRoomAttachment = async (
|
||||
userId: string,
|
||||
tenantId: number | null,
|
||||
@@ -1467,13 +1932,18 @@ export function matrixService(server: FastifyInstance) {
|
||||
))
|
||||
.where(eq(authTenantUsers.tenant_id, tenant.id))
|
||||
|
||||
return rows
|
||||
const users = rows
|
||||
.filter((row) => row.profileActive !== false)
|
||||
.map((row) => ({
|
||||
userId: row.userId,
|
||||
email: row.email,
|
||||
displayName: row.fullName || `${row.firstName || ""} ${row.lastName || ""}`.trim() || row.email,
|
||||
}))
|
||||
|
||||
return await Promise.all(users.map(async (user) => ({
|
||||
...user,
|
||||
matrixUserId: await matrixUserIdForUser(user.userId, tenant.id),
|
||||
})))
|
||||
}
|
||||
|
||||
const inviteMatrixUserToRoom = async (
|
||||
@@ -1567,6 +2037,102 @@ export function matrixService(server: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
const inviteTenantRoomMember = async (
|
||||
requestingUserId: string,
|
||||
tenantId: number | null,
|
||||
options: MatrixTenantRoomOptions = {},
|
||||
targetUserId: string
|
||||
) => {
|
||||
if (!targetUserId) {
|
||||
throw Object.assign(
|
||||
new Error("Benutzer ist erforderlich"),
|
||||
{ statusCode: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const users = await listTenantCommunicationUsers(tenantId)
|
||||
const targetUser = users.find((user) => user.userId === targetUserId)
|
||||
|
||||
if (!targetUser) {
|
||||
throw Object.assign(
|
||||
new Error("Benutzer gehört nicht zum aktiven Mandanten"),
|
||||
{ statusCode: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const room = await provisionTenantRoom(requestingUserId, tenantId, options)
|
||||
const account = await provisionCurrentUser(targetUser.userId, tenantId)
|
||||
const inviteStatus = await inviteMatrixUserToRoom(
|
||||
{ roomId: room.roomId },
|
||||
account.matrixUserId,
|
||||
`FEDEO-Einladung: ${room.name}`
|
||||
)
|
||||
await ensureCurrentUserJoinedRoom(targetUser.userId, tenantId, {
|
||||
roomId: room.roomId,
|
||||
alias: room.alias,
|
||||
})
|
||||
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
alias: room.alias,
|
||||
key: room.key,
|
||||
name: room.name,
|
||||
userId: targetUser.userId,
|
||||
email: targetUser.email,
|
||||
displayName: targetUser.displayName,
|
||||
matrixUserId: account.matrixUserId,
|
||||
status: inviteStatus === "invited" ? "joined" : inviteStatus,
|
||||
ok: true,
|
||||
}
|
||||
}
|
||||
|
||||
const removeTenantRoomMember = async (
|
||||
requestingUserId: string,
|
||||
tenantId: number | null,
|
||||
options: MatrixTenantRoomOptions = {},
|
||||
matrixUserId: string
|
||||
) => {
|
||||
if (!matrixUserId) {
|
||||
throw Object.assign(
|
||||
new Error("Matrix-Benutzer ist erforderlich"),
|
||||
{ statusCode: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const room = await provisionTenantRoom(requestingUserId, tenantId, options)
|
||||
const session = await createAccessTokenForUser(requestingUserId, tenantId)
|
||||
|
||||
if (matrixUserId === session.matrixUserId) {
|
||||
throw Object.assign(
|
||||
new Error("Du kannst dich nicht selbst aus dem Raum entfernen"),
|
||||
{ statusCode: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const serviceLogin = await ensureServiceAccessToken()
|
||||
await requestMatrixJson(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/kick`,
|
||||
serviceLogin.accessToken,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
user_id: matrixUserId,
|
||||
reason: "FEDEO-Mitgliederverwaltung",
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
matrixJoinedRoomCache.delete(`${matrixUserId}:${room.roomId}`)
|
||||
return {
|
||||
success: true,
|
||||
roomId: room.roomId,
|
||||
alias: room.alias,
|
||||
key: room.key,
|
||||
matrixUserId,
|
||||
}
|
||||
}
|
||||
|
||||
const getGeneralRoomMessages = (userId: string, tenantId: number | null) =>
|
||||
getTenantRoomMessages(userId, tenantId, {
|
||||
key: "allgemein",
|
||||
@@ -1579,7 +2145,12 @@ export function matrixService(server: FastifyInstance) {
|
||||
name: "Allgemeiner Chat",
|
||||
})
|
||||
|
||||
const sendGeneralRoomMessage = (userId: string, tenantId: number | null, text: string) =>
|
||||
const sendGeneralRoomMessage = (
|
||||
userId: string,
|
||||
tenantId: number | null,
|
||||
text: string,
|
||||
messageOptions: MatrixMessageOptions = {}
|
||||
) =>
|
||||
sendTenantRoomMessage(
|
||||
userId,
|
||||
tenantId,
|
||||
@@ -1587,7 +2158,8 @@ export function matrixService(server: FastifyInstance) {
|
||||
key: "allgemein",
|
||||
name: "Allgemeiner Chat",
|
||||
},
|
||||
text
|
||||
text,
|
||||
messageOptions
|
||||
)
|
||||
|
||||
const sendGeneralRoomAttachment = (userId: string, tenantId: number | null, attachment: MatrixAttachmentInput) =>
|
||||
@@ -1609,16 +2181,25 @@ export function matrixService(server: FastifyInstance) {
|
||||
getTenantSpaceStatus,
|
||||
provisionCurrentTenantSpace,
|
||||
listTenantRooms,
|
||||
listTenantCommunicationUsers,
|
||||
getTenantRoomStatus,
|
||||
provisionTenantRoom,
|
||||
createAccessTokenForUser,
|
||||
getTenantRoomMessages,
|
||||
getTenantRoomMembers,
|
||||
searchTenantRoomMessages,
|
||||
syncTenantRoomEvents,
|
||||
sendTenantRoomMessage,
|
||||
sendTenantRoomReaction,
|
||||
editTenantRoomMessage,
|
||||
redactTenantRoomMessage,
|
||||
markTenantRoomRead,
|
||||
sendTenantRoomAttachment,
|
||||
getMediaContent,
|
||||
createElementRoomSession,
|
||||
createLiveKitRoomSession,
|
||||
inviteTenantRoomMember,
|
||||
removeTenantRoomMember,
|
||||
syncTenantRoomMembers,
|
||||
getGeneralRoomMessages,
|
||||
getGeneralRoomMembers,
|
||||
|
||||
174
backend/src/modules/system-status.service.ts
Normal file
174
backend/src/modules/system-status.service.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { matrixService } from "./matrix.service"
|
||||
|
||||
type MetricSample = {
|
||||
labels: Record<string, string>
|
||||
value: number
|
||||
}
|
||||
|
||||
const metricLinePattern = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+(-?(?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?|-?Inf|NaN)$/i
|
||||
|
||||
const nodeExporterUrl = () =>
|
||||
(process.env.NODE_EXPORTER_URL || "http://node-exporter:9100").replace(/\/+$/, "")
|
||||
|
||||
const s3EndpointUrl = () =>
|
||||
(process.env.S3_ENDPOINT || "").replace(/\/+$/, "")
|
||||
|
||||
const parseLabels = (value = "") => {
|
||||
const labels: Record<string, string> = {}
|
||||
const labelPattern = /(\w+)="((?:\\"|[^"])*)"/g
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = labelPattern.exec(value))) {
|
||||
labels[match[1]] = match[2].replace(/\\"/g, "\"")
|
||||
}
|
||||
|
||||
return labels
|
||||
}
|
||||
|
||||
const parsePrometheusMetrics = (text: string) => {
|
||||
const metrics = new Map<string, MetricSample[]>()
|
||||
|
||||
for (const line of text.split("\n")) {
|
||||
if (!line || line.startsWith("#")) continue
|
||||
|
||||
const match = line.match(metricLinePattern)
|
||||
if (!match) continue
|
||||
|
||||
const value = Number(match[3])
|
||||
if (!Number.isFinite(value)) continue
|
||||
|
||||
const samples = metrics.get(match[1]) || []
|
||||
samples.push({
|
||||
labels: parseLabels(match[2]),
|
||||
value,
|
||||
})
|
||||
metrics.set(match[1], samples)
|
||||
}
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
const firstMetricValue = (metrics: Map<string, MetricSample[]>, name: string) =>
|
||||
metrics.get(name)?.[0]?.value ?? null
|
||||
|
||||
const findMetricValue = (
|
||||
metrics: Map<string, MetricSample[]>,
|
||||
name: string,
|
||||
predicate: (sample: MetricSample) => boolean
|
||||
) => metrics.get(name)?.find(predicate)?.value ?? null
|
||||
|
||||
const serviceState = (ok: boolean, detail?: Record<string, any>) => ({
|
||||
ok,
|
||||
status: ok ? "ok" : "error",
|
||||
...detail,
|
||||
})
|
||||
|
||||
const checkHttp = async (url: string, timeoutMs = 3000) => {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal })
|
||||
return serviceState(response.ok, {
|
||||
httpStatus: response.status,
|
||||
url,
|
||||
})
|
||||
} catch (err: any) {
|
||||
return serviceState(false, {
|
||||
url,
|
||||
error: err?.message || "HTTP-Abfrage fehlgeschlagen",
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export const buildSystemStatus = async (server: FastifyInstance) => {
|
||||
const checkedAt = new Date()
|
||||
const nodeExporterMetricsUrl = `${nodeExporterUrl()}/metrics`
|
||||
let nodeMetrics: Map<string, MetricSample[]> | null = null
|
||||
let nodeExporterError: string | null = null
|
||||
|
||||
try {
|
||||
const response = await fetch(nodeExporterMetricsUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Node Exporter antwortet mit ${response.status}`)
|
||||
}
|
||||
nodeMetrics = parsePrometheusMetrics(await response.text())
|
||||
} catch (err: any) {
|
||||
nodeExporterError = err?.message || "Node Exporter nicht erreichbar"
|
||||
}
|
||||
|
||||
const memoryTotal = nodeMetrics ? firstMetricValue(nodeMetrics, "node_memory_MemTotal_bytes") : null
|
||||
const memoryAvailable = nodeMetrics ? firstMetricValue(nodeMetrics, "node_memory_MemAvailable_bytes") : null
|
||||
const rootSize = nodeMetrics
|
||||
? findMetricValue(nodeMetrics, "node_filesystem_size_bytes", (sample) => sample.labels.mountpoint === "/")
|
||||
: null
|
||||
const rootAvailable = nodeMetrics
|
||||
? findMetricValue(nodeMetrics, "node_filesystem_avail_bytes", (sample) => sample.labels.mountpoint === "/")
|
||||
: null
|
||||
const bootTime = nodeMetrics ? firstMetricValue(nodeMetrics, "node_boot_time_seconds") : null
|
||||
const cpuCount = nodeMetrics
|
||||
? new Set((nodeMetrics.get("node_cpu_seconds_total") || [])
|
||||
.filter((sample) => sample.labels.mode === "idle")
|
||||
.map((sample) => sample.labels.cpu)).size
|
||||
: null
|
||||
const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null
|
||||
|
||||
const databaseCheck = await server.db.execute("SELECT NOW() as now")
|
||||
const matrixStatus = await matrixService(server).getStatus().catch((err: any) => ({
|
||||
reachable: false,
|
||||
error: err?.message || "Matrix-Status nicht verfügbar",
|
||||
}))
|
||||
const minioUrl = s3EndpointUrl()
|
||||
|
||||
return {
|
||||
checkedAt: checkedAt.toISOString(),
|
||||
backend: {
|
||||
status: "ok",
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
nodeVersion: process.version,
|
||||
environment: process.env.NODE_ENV || "development",
|
||||
},
|
||||
server: {
|
||||
status: nodeMetrics ? "ok" : "unavailable",
|
||||
nodeExporterUrl: nodeExporterMetricsUrl,
|
||||
error: nodeExporterError,
|
||||
hostname: uname?.nodename || null,
|
||||
kernel: uname?.release || null,
|
||||
cpuCount,
|
||||
load: {
|
||||
one: nodeMetrics ? firstMetricValue(nodeMetrics, "node_load1") : null,
|
||||
five: nodeMetrics ? firstMetricValue(nodeMetrics, "node_load5") : null,
|
||||
fifteen: nodeMetrics ? firstMetricValue(nodeMetrics, "node_load15") : null,
|
||||
},
|
||||
memory: {
|
||||
totalBytes: memoryTotal,
|
||||
availableBytes: memoryAvailable,
|
||||
usedBytes: memoryTotal !== null && memoryAvailable !== null ? memoryTotal - memoryAvailable : null,
|
||||
usedPercent: memoryTotal ? Math.round(((memoryTotal - (memoryAvailable || 0)) / memoryTotal) * 1000) / 10 : null,
|
||||
},
|
||||
disk: {
|
||||
rootTotalBytes: rootSize,
|
||||
rootAvailableBytes: rootAvailable,
|
||||
rootUsedBytes: rootSize !== null && rootAvailable !== null ? rootSize - rootAvailable : null,
|
||||
rootUsedPercent: rootSize ? Math.round(((rootSize - (rootAvailable || 0)) / rootSize) * 1000) / 10 : null,
|
||||
},
|
||||
uptimeSeconds: bootTime ? Math.max(0, Math.round(Date.now() / 1000 - bootTime)) : null,
|
||||
},
|
||||
services: {
|
||||
database: serviceState(true, {
|
||||
checkedAt: String(databaseCheck.rows?.[0]?.now || checkedAt.toISOString()),
|
||||
}),
|
||||
nodeExporter: serviceState(Boolean(nodeMetrics), {
|
||||
url: nodeExporterMetricsUrl,
|
||||
error: nodeExporterError,
|
||||
}),
|
||||
matrix: serviceState(Boolean((matrixStatus as any).reachable), matrixStatus as Record<string, any>),
|
||||
minio: minioUrl ? await checkHttp(`${minioUrl}/minio/health/live`) : serviceState(false, {
|
||||
error: "S3_ENDPOINT ist nicht gesetzt",
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { sendMail } from "../utils/mailer";
|
||||
import { ensureTenantBaseData } from "../modules/bootstrap.service";
|
||||
import { buildTenantFullExport, importTenantFullExport } from "../utils/tenantFullExport";
|
||||
import type { TenantFullExport } from "../utils/tenantFullExport";
|
||||
import { buildSystemStatus } from "../modules/system-status.service";
|
||||
|
||||
export default async function adminRoutes(server: FastifyInstance) {
|
||||
const deriveNameFromEmail = (email: string) => {
|
||||
@@ -393,6 +394,21 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET /admin/system-status
|
||||
// -------------------------------------------------------------
|
||||
server.get("/admin/system-status", async (req, reply) => {
|
||||
try {
|
||||
const currentUser = await requireAdmin(req, reply);
|
||||
if (!currentUser) return;
|
||||
|
||||
return await buildSystemStatus(server);
|
||||
} catch (err) {
|
||||
console.error("ERROR /admin/system-status:", err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// POST /admin/users
|
||||
// -------------------------------------------------------------
|
||||
|
||||
@@ -603,6 +603,15 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
||||
}
|
||||
})
|
||||
|
||||
server.get("/communication/matrix/users", async (req, reply) => {
|
||||
try {
|
||||
const users = await matrix.listTenantCommunicationUsers(req.user.tenant_id)
|
||||
return { users }
|
||||
} catch (err: any) {
|
||||
return handleMatrixError(req, reply, err, "Matrix users failed")
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/communication/matrix/rooms/general/session", async (req, reply) => {
|
||||
try {
|
||||
return await matrix.createElementRoomSession(req.user.user_id, req.user.tenant_id, {
|
||||
@@ -641,8 +650,10 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
||||
|
||||
server.post("/communication/matrix/rooms/general/messages", async (req, reply) => {
|
||||
try {
|
||||
const body = req.body as { text?: string }
|
||||
const message = await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "")
|
||||
const body = req.body as { text?: string; replyToEventId?: string }
|
||||
const message = await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "", {
|
||||
replyToEventId: body.replyToEventId,
|
||||
})
|
||||
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat")
|
||||
await notifyUsersAboutChatMessage(req, room, message, body.text || "")
|
||||
return message
|
||||
@@ -688,7 +699,12 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
||||
try {
|
||||
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
|
||||
const params = req.params as { roomKey: string }
|
||||
return await markRoomNotificationsRead(req.user.tenant_id, req.user.user_id, params.roomKey)
|
||||
const body = (req.body || {}) as { eventId?: string }
|
||||
const result = await markRoomNotificationsRead(req.user.tenant_id, req.user.user_id, params.roomKey)
|
||||
if (body.eventId) {
|
||||
await matrix.markTenantRoomRead(req.user.user_id, req.user.tenant_id, roomOptionsFromRequest(req), body.eventId)
|
||||
}
|
||||
return result
|
||||
} catch (err: any) {
|
||||
return handleMatrixError(req, reply, err, "Matrix room read state failed")
|
||||
}
|
||||
@@ -706,6 +722,35 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
||||
}
|
||||
})
|
||||
|
||||
server.get("/communication/matrix/rooms/:roomKey/search", async (req, reply) => {
|
||||
try {
|
||||
const query = req.query as { q?: string }
|
||||
return await matrix.searchTenantRoomMessages(
|
||||
req.user.user_id,
|
||||
req.user.tenant_id,
|
||||
roomOptionsFromRequest(req),
|
||||
query.q || ""
|
||||
)
|
||||
} catch (err: any) {
|
||||
return handleMatrixError(req, reply, err, "Matrix search failed")
|
||||
}
|
||||
})
|
||||
|
||||
server.get("/communication/matrix/rooms/:roomKey/sync", async (req, reply) => {
|
||||
try {
|
||||
const query = req.query as { since?: string; initial?: string }
|
||||
return await matrix.syncTenantRoomEvents(
|
||||
req.user.user_id,
|
||||
req.user.tenant_id,
|
||||
roomOptionsFromRequest(req),
|
||||
query.since,
|
||||
query.initial === "1"
|
||||
)
|
||||
} catch (err: any) {
|
||||
return handleMatrixError(req, reply, err, "Matrix sync failed")
|
||||
}
|
||||
})
|
||||
|
||||
server.get("/communication/matrix/rooms/:roomKey/members", async (req, reply) => {
|
||||
try {
|
||||
return await matrix.getTenantRoomMembers(
|
||||
@@ -718,6 +763,34 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/communication/matrix/rooms/:roomKey/members/invite", async (req, reply) => {
|
||||
try {
|
||||
const body = req.body as { userId?: string }
|
||||
return await matrix.inviteTenantRoomMember(
|
||||
req.user.user_id,
|
||||
req.user.tenant_id,
|
||||
roomOptionsFromRequest(req),
|
||||
body.userId || ""
|
||||
)
|
||||
} catch (err: any) {
|
||||
return handleMatrixError(req, reply, err, "Matrix member invite failed")
|
||||
}
|
||||
})
|
||||
|
||||
server.delete("/communication/matrix/rooms/:roomKey/members/:matrixUserId", async (req, reply) => {
|
||||
try {
|
||||
const params = req.params as { matrixUserId: string }
|
||||
return await matrix.removeTenantRoomMember(
|
||||
req.user.user_id,
|
||||
req.user.tenant_id,
|
||||
roomOptionsFromRequest(req),
|
||||
params.matrixUserId
|
||||
)
|
||||
} catch (err: any) {
|
||||
return handleMatrixError(req, reply, err, "Matrix member remove failed")
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/communication/matrix/rooms/:roomKey/session", async (req, reply) => {
|
||||
try {
|
||||
return await matrix.createElementRoomSession(
|
||||
@@ -759,12 +832,15 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
||||
|
||||
server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
|
||||
try {
|
||||
const body = req.body as { text?: string }
|
||||
const body = req.body as { text?: string; replyToEventId?: string }
|
||||
const message = await matrix.sendTenantRoomMessage(
|
||||
req.user.user_id,
|
||||
req.user.tenant_id,
|
||||
roomOptionsFromRequest(req),
|
||||
body.text || ""
|
||||
body.text || "",
|
||||
{
|
||||
replyToEventId: body.replyToEventId,
|
||||
}
|
||||
)
|
||||
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key)
|
||||
await notifyUsersAboutChatMessage(req, room, message, body.text || "")
|
||||
@@ -774,6 +850,52 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/communication/matrix/rooms/:roomKey/messages/:eventId/reactions", async (req, reply) => {
|
||||
try {
|
||||
const params = req.params as { eventId: string }
|
||||
const body = req.body as { key?: string }
|
||||
return await matrix.sendTenantRoomReaction(
|
||||
req.user.user_id,
|
||||
req.user.tenant_id,
|
||||
roomOptionsFromRequest(req),
|
||||
params.eventId,
|
||||
body.key || ""
|
||||
)
|
||||
} catch (err: any) {
|
||||
return handleMatrixError(req, reply, err, "Matrix reaction send failed")
|
||||
}
|
||||
})
|
||||
|
||||
server.put("/communication/matrix/rooms/:roomKey/messages/:eventId", async (req, reply) => {
|
||||
try {
|
||||
const params = req.params as { eventId: string }
|
||||
const body = req.body as { text?: string }
|
||||
return await matrix.editTenantRoomMessage(
|
||||
req.user.user_id,
|
||||
req.user.tenant_id,
|
||||
roomOptionsFromRequest(req),
|
||||
params.eventId,
|
||||
body.text || ""
|
||||
)
|
||||
} catch (err: any) {
|
||||
return handleMatrixError(req, reply, err, "Matrix message edit failed")
|
||||
}
|
||||
})
|
||||
|
||||
server.delete("/communication/matrix/rooms/:roomKey/messages/:eventId", async (req, reply) => {
|
||||
try {
|
||||
const params = req.params as { eventId: string }
|
||||
return await matrix.redactTenantRoomMessage(
|
||||
req.user.user_id,
|
||||
req.user.tenant_id,
|
||||
roomOptionsFromRequest(req),
|
||||
params.eventId
|
||||
)
|
||||
} catch (err: any) {
|
||||
return handleMatrixError(req, reply, err, "Matrix message delete failed")
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/communication/matrix/rooms/:roomKey/attachments", async (req, reply) => {
|
||||
try {
|
||||
const attachment = await uploadedAttachmentFromRequest(req)
|
||||
|
||||
@@ -81,8 +81,7 @@ services:
|
||||
- internal
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
image: git.federspiel.tech/flfeders/fedeo/backend:dev
|
||||
container_name: fedeo-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -139,6 +138,7 @@ services:
|
||||
MATRIX_SERVICE_USER_LOCALPART: ${MATRIX_SERVICE_USER_LOCALPART:-fedeo_service}
|
||||
LIVEKIT_KEY: ${LIVEKIT_KEY:-fedeo-livekit}
|
||||
LIVEKIT_SECRET: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace}
|
||||
NODE_EXPORTER_URL: ${NODE_EXPORTER_URL:-http://node-exporter:9100}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)
|
||||
@@ -152,9 +152,25 @@ services:
|
||||
- web
|
||||
- internal
|
||||
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.8.2
|
||||
container_name: fedeo-node-exporter
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- --path.procfs=/host/proc
|
||||
- --path.sysfs=/host/sys
|
||||
- --path.rootfs=/rootfs
|
||||
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)
|
||||
pid: host
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/rootfs:ro,rslave
|
||||
networks:
|
||||
- internal
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
image: git.federspiel.tech/flfeders/fedeo/frontend:dev
|
||||
container_name: fedeo-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
|
||||
@@ -56,6 +56,7 @@ services:
|
||||
- WEB_PUSH_PUBLIC_KEY=${WEB_PUSH_PUBLIC_KEY:-}
|
||||
- WEB_PUSH_PRIVATE_KEY=${WEB_PUSH_PRIVATE_KEY:-}
|
||||
- WEB_PUSH_SUBJECT=${WEB_PUSH_SUBJECT:-mailto:admin@example.com}
|
||||
- NODE_EXPORTER_URL=${NODE_EXPORTER_URL:-http://node-exporter:9100}
|
||||
networks:
|
||||
- traefik
|
||||
labels:
|
||||
@@ -74,6 +75,23 @@ services:
|
||||
- "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" #
|
||||
- "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip"
|
||||
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.8.2
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- --path.procfs=/host/proc
|
||||
- --path.sysfs=/host/sys
|
||||
- --path.rootfs=/rootfs
|
||||
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)
|
||||
pid: host
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/rootfs:ro,rslave
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
matrix-db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -355,6 +355,11 @@ const links = computed(() => {
|
||||
to: "/administration/tenants",
|
||||
icon: "i-heroicons-building-office-2",
|
||||
},
|
||||
{
|
||||
label: "Systemstatus",
|
||||
to: "/administration/system",
|
||||
icon: "i-heroicons-server-stack",
|
||||
},
|
||||
] : []
|
||||
|
||||
const visibleOrganisationChildren = visibleItems(organisationChildren)
|
||||
|
||||
@@ -55,6 +55,34 @@ export type TenantImportResult = {
|
||||
files: { restored: number; skipped: number }
|
||||
}
|
||||
|
||||
export type SystemStatus = {
|
||||
checkedAt: string
|
||||
backend: {
|
||||
status: string
|
||||
uptimeSeconds: number
|
||||
nodeVersion: string
|
||||
environment: string
|
||||
}
|
||||
server: {
|
||||
status: string
|
||||
nodeExporterUrl: string
|
||||
error?: string | null
|
||||
hostname?: string | null
|
||||
kernel?: string | null
|
||||
cpuCount?: number | null
|
||||
uptimeSeconds?: number | null
|
||||
load: { one?: number | null; five?: number | null; fifteen?: number | null }
|
||||
memory: { totalBytes?: number | null; availableBytes?: number | null; usedBytes?: number | null; usedPercent?: number | null }
|
||||
disk: { rootTotalBytes?: number | null; rootAvailableBytes?: number | null; rootUsedBytes?: number | null; rootUsedPercent?: number | null }
|
||||
}
|
||||
services: Record<string, {
|
||||
ok: boolean
|
||||
status: string
|
||||
error?: string | null
|
||||
[key: string]: any
|
||||
}>
|
||||
}
|
||||
|
||||
export const useAdmin = () => {
|
||||
const { $api } = useNuxtApp()
|
||||
|
||||
@@ -130,8 +158,13 @@ export const useAdmin = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const getSystemStatus = async (): Promise<SystemStatus> => {
|
||||
return await $api("/api/admin/system-status")
|
||||
}
|
||||
|
||||
return {
|
||||
getOverview,
|
||||
getSystemStatus,
|
||||
createUser,
|
||||
createUserForProfile,
|
||||
updateUser,
|
||||
|
||||
228
frontend/pages/administration/system.vue
Normal file
228
frontend/pages/administration/system.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import type { SystemStatus } from "~/composables/useAdmin"
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
const admin = useAdmin()
|
||||
|
||||
const loading = ref(true)
|
||||
const status = ref<SystemStatus | null>(null)
|
||||
|
||||
const serviceLabels: Record<string, string> = {
|
||||
backend: "Backend",
|
||||
database: "Datenbank",
|
||||
nodeExporter: "Node Exporter",
|
||||
matrix: "Matrix",
|
||||
minio: "Dateispeicher",
|
||||
}
|
||||
|
||||
const formatBytes = (value?: number | null) => {
|
||||
const bytes = Number(value || 0)
|
||||
if (!bytes) return "-"
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
const formatDuration = (seconds?: number | null) => {
|
||||
const value = Number(seconds || 0)
|
||||
if (!value) return "-"
|
||||
|
||||
const days = Math.floor(value / 86400)
|
||||
const hours = Math.floor((value % 86400) / 3600)
|
||||
const minutes = Math.floor((value % 3600) / 60)
|
||||
|
||||
if (days) return `${days} d ${hours} h`
|
||||
if (hours) return `${hours} h ${minutes} min`
|
||||
return `${minutes} min`
|
||||
}
|
||||
|
||||
const loadStatus = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
status.value = await admin.getSystemStatus()
|
||||
} catch (err: any) {
|
||||
console.error("[administration/system]", err)
|
||||
toast.add({
|
||||
title: "Systemstatus konnte nicht geladen werden",
|
||||
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
||||
color: "red",
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const serviceRows = computed(() => {
|
||||
const services = status.value?.services || {}
|
||||
return Object.entries(services).map(([key, service]) => ({
|
||||
key,
|
||||
label: serviceLabels[key] || key,
|
||||
...service,
|
||||
}))
|
||||
})
|
||||
|
||||
const overallStatus = computed(() => {
|
||||
if (!status.value) return "unavailable"
|
||||
return serviceRows.value.every((service) => service.ok) ? "ok" : "warning"
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.user?.is_admin) {
|
||||
await router.push("/")
|
||||
return
|
||||
}
|
||||
|
||||
await loadStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar title="Administration: Systemstatus">
|
||||
<template #right>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:loading="loading"
|
||||
@click="loadStatus"
|
||||
>
|
||||
Aktualisieren
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UDashboardPanelContent>
|
||||
<div class="space-y-6">
|
||||
<UAlert
|
||||
:icon="overallStatus === 'ok' ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'"
|
||||
:color="overallStatus === 'ok' ? 'success' : 'warning'"
|
||||
variant="soft"
|
||||
:title="overallStatus === 'ok' ? 'System läuft' : 'System prüfen'"
|
||||
:description="status?.checkedAt ? `Letzte Prüfung: ${new Date(status.checkedAt).toLocaleString('de-DE')}` : 'Noch keine Prüfung geladen.'"
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-3">
|
||||
<UCard :ui="{ root: 'rounded-lg' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-cpu-chip" class="size-5 text-primary" />
|
||||
<h2 class="text-base font-semibold text-highlighted">Server</h2>
|
||||
</div>
|
||||
</template>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between gap-3">
|
||||
<span class="text-muted">Host</span>
|
||||
<span class="truncate text-highlighted">{{ status?.server.hostname || "-" }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3">
|
||||
<span class="text-muted">CPU</span>
|
||||
<span class="text-highlighted">{{ status?.server.cpuCount || "-" }} Kerne</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3">
|
||||
<span class="text-muted">Load</span>
|
||||
<span class="text-highlighted">
|
||||
{{ status?.server.load.one ?? "-" }} · {{ status?.server.load.five ?? "-" }} · {{ status?.server.load.fifteen ?? "-" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3">
|
||||
<span class="text-muted">Uptime</span>
|
||||
<span class="text-highlighted">{{ formatDuration(status?.server.uptimeSeconds) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard :ui="{ root: 'rounded-lg' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-circle-stack" class="size-5 text-primary" />
|
||||
<h2 class="text-base font-semibold text-highlighted">Speicher</h2>
|
||||
</div>
|
||||
</template>
|
||||
<div class="space-y-4 text-sm">
|
||||
<div>
|
||||
<div class="mb-1 flex justify-between">
|
||||
<span class="text-muted">RAM</span>
|
||||
<span class="text-highlighted">{{ status?.server.memory.usedPercent ?? "-" }}%</span>
|
||||
</div>
|
||||
<UProgress :model-value="status?.server.memory.usedPercent || 0" />
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ formatBytes(status?.server.memory.usedBytes) }} von {{ formatBytes(status?.server.memory.totalBytes) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 flex justify-between">
|
||||
<span class="text-muted">Root-Dateisystem</span>
|
||||
<span class="text-highlighted">{{ status?.server.disk.rootUsedPercent ?? "-" }}%</span>
|
||||
</div>
|
||||
<UProgress :model-value="status?.server.disk.rootUsedPercent || 0" />
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ formatBytes(status?.server.disk.rootUsedBytes) }} von {{ formatBytes(status?.server.disk.rootTotalBytes) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard :ui="{ root: 'rounded-lg' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-server-stack" class="size-5 text-primary" />
|
||||
<h2 class="text-base font-semibold text-highlighted">Backend</h2>
|
||||
</div>
|
||||
</template>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between gap-3">
|
||||
<span class="text-muted">Status</span>
|
||||
<UBadge color="success" variant="soft">{{ status?.backend.status || "-" }}</UBadge>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3">
|
||||
<span class="text-muted">Laufzeit</span>
|
||||
<span class="text-highlighted">{{ formatDuration(status?.backend.uptimeSeconds) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3">
|
||||
<span class="text-muted">Node.js</span>
|
||||
<span class="text-highlighted">{{ status?.backend.nodeVersion || "-" }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3">
|
||||
<span class="text-muted">Umgebung</span>
|
||||
<span class="text-highlighted">{{ status?.backend.environment || "-" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UCard :ui="{ root: 'rounded-lg' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-signal" class="size-5 text-primary" />
|
||||
<h2 class="text-base font-semibold text-highlighted">Dienste</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="divide-y divide-default">
|
||||
<div
|
||||
v-for="service in serviceRows"
|
||||
:key="service.key"
|
||||
class="flex items-center justify-between gap-4 py-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-highlighted">{{ service.label }}</p>
|
||||
<p class="truncate text-xs text-muted">
|
||||
{{ service.error || service.url || service.publicBaseUrl || service.status }}
|
||||
</p>
|
||||
</div>
|
||||
<UBadge
|
||||
:color="service.ok ? 'success' : 'error'"
|
||||
variant="soft"
|
||||
>
|
||||
{{ service.ok ? "OK" : "Fehler" }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</UDashboardPanelContent>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
1
mobile/.nvmrc
Normal file
1
mobile/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
22.22.3
|
||||
@@ -1,50 +1,37 @@
|
||||
# Welcome to your Expo app 👋
|
||||
# FEDEO Mobile
|
||||
|
||||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||
Expo/React-Native-App für FEDEO. Der iOS-TestFlight-Build wird als Store-Build erstellt und enthält das JavaScript-Bundle. Metro wird dafür nicht benötigt.
|
||||
|
||||
## Get started
|
||||
## Voraussetzungen
|
||||
|
||||
1. Install dependencies
|
||||
- Node.js `22.22.3` verwenden, siehe `.nvmrc`.
|
||||
- EAS CLI installieren oder über `npx eas-cli` ausführen.
|
||||
- Apple Developer Zugriff für `software.federspiel.fedeo`.
|
||||
- Produktions-API ist im EAS-Profil `testflight` auf `https://app.fedeo.de/backend` gesetzt.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
## Entwicklung im Simulator
|
||||
|
||||
2. Start the app
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
In the output, you'll find options to open the app in a
|
||||
|
||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||
|
||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||
|
||||
## Get a fresh project
|
||||
|
||||
When you're ready, run:
|
||||
Wenn Port `8081` lokal belegt ist, Metro auf einem freien Port starten und den Simulator auf diesen Port setzen:
|
||||
|
||||
```bash
|
||||
npm run reset-project
|
||||
npx --yes -p node@22 node ./node_modules/expo/bin/cli start --dev-client --localhost --port 8082 --clear
|
||||
xcrun simctl spawn booted defaults write software.federspiel.fedeo RCT_jsLocation '127.0.0.1:8082'
|
||||
xcrun simctl spawn booted defaults write software.federspiel.fedeo RCT_packager_scheme 'http'
|
||||
xcrun simctl terminate booted software.federspiel.fedeo
|
||||
xcrun simctl launch booted software.federspiel.fedeo
|
||||
```
|
||||
|
||||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||
## TestFlight vorbereiten
|
||||
|
||||
## Learn more
|
||||
```bash
|
||||
npm run preflight:testflight
|
||||
npm run build:ios:testflight
|
||||
```
|
||||
|
||||
To learn more about developing your project with Expo, look at the following resources:
|
||||
Nach erfolgreichem EAS-Build:
|
||||
|
||||
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||
```bash
|
||||
npm run submit:ios:testflight
|
||||
```
|
||||
|
||||
## Join the community
|
||||
|
||||
Join our community of developers creating universal apps.
|
||||
|
||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||
Der TestFlight-Build nutzt das EAS-Profil `testflight` aus `eas.json` mit Store-Distribution und automatischer Buildnummer-Erhöhung.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "FEDEO",
|
||||
"slug": "fedeo-mobile",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "fedeo",
|
||||
@@ -10,8 +10,8 @@
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "de.fedeo.mobile",
|
||||
"buildNumber": "1",
|
||||
"bundleIdentifier": "software.federspiel.fedeo",
|
||||
"buildNumber": "3",
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.",
|
||||
"NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "de.fedeo.mobile",
|
||||
"package": "software.federspiel.fedeo",
|
||||
"permissions": [
|
||||
"android.permission.BLUETOOTH_SCAN",
|
||||
"android.permission.BLUETOOTH_CONNECT",
|
||||
@@ -54,7 +54,8 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"react-native-ble-plx"
|
||||
"react-native-ble-plx",
|
||||
"expo-web-browser"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
|
||||
@@ -50,6 +50,13 @@ export default function TabLayout() {
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={24} name="folder.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="communication"
|
||||
options={{
|
||||
title: 'Kommunikation',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={24} name="message.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="time"
|
||||
options={{
|
||||
|
||||
918
mobile/app/(tabs)/communication.tsx
Normal file
918
mobile/app/(tabs)/communication.tsx
Normal file
@@ -0,0 +1,918 @@
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import {
|
||||
createMatrixRoom,
|
||||
deleteMatrixMessage,
|
||||
editMatrixMessage,
|
||||
fetchMatrixIdentity,
|
||||
fetchMatrixMembers,
|
||||
fetchMatrixMessages,
|
||||
fetchMatrixRooms,
|
||||
fetchMatrixStatus,
|
||||
fetchMatrixUsers,
|
||||
inviteMatrixMember,
|
||||
markMatrixRoomRead,
|
||||
MatrixMember,
|
||||
MatrixMessage,
|
||||
MatrixRoom,
|
||||
MatrixStatus,
|
||||
MatrixUser,
|
||||
provisionMatrixRoom,
|
||||
provisionMatrixUser,
|
||||
reactToMatrixMessage,
|
||||
removeMatrixMember,
|
||||
sendMatrixMessage,
|
||||
syncMatrixMembers,
|
||||
syncMatrixRoom,
|
||||
uploadMatrixAttachment,
|
||||
} from '@/src/lib/api';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
|
||||
const PRIMARY = '#69c350';
|
||||
const REACTION_PRESETS = ['👍', '✅', '👀', '🙏'];
|
||||
|
||||
type DialogMode = 'create-room' | 'edit-message' | 'invite-member' | null;
|
||||
|
||||
function normalizeRoomKey(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
function messageTime(value: MatrixMessage['timestamp']): string {
|
||||
if (!value) return '';
|
||||
const date = typeof value === 'number' ? new Date(value) : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function messagePreview(message: MatrixMessage): string {
|
||||
return message.body || message.attachment?.fileName || 'Nachricht';
|
||||
}
|
||||
|
||||
function sortMessages(messages: MatrixMessage[]): MatrixMessage[] {
|
||||
return [...messages].sort((a, b) => {
|
||||
const left = a.timestamp ? new Date(a.timestamp).getTime() : 0;
|
||||
const right = b.timestamp ? new Date(b.timestamp).getTime() : 0;
|
||||
return left - right;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeMessages(current: MatrixMessage[], incoming: MatrixMessage[]): MatrixMessage[] {
|
||||
const byId = new Map<string, MatrixMessage>();
|
||||
for (const message of current) byId.set(message.id, message);
|
||||
for (const message of incoming || []) byId.set(message.id, { ...byId.get(message.id), ...message });
|
||||
return sortMessages(Array.from(byId.values()));
|
||||
}
|
||||
|
||||
function applyReplacements(current: MatrixMessage[], replacements: MatrixMessage[] = []): MatrixMessage[] {
|
||||
if (!replacements.length) return current;
|
||||
const byTarget = new Map<string, MatrixMessage>();
|
||||
for (const replacement of replacements) {
|
||||
const targetId = String(replacement.targetEventId || replacement.replyToEventId || replacement.id || '');
|
||||
if (targetId) byTarget.set(targetId, replacement);
|
||||
}
|
||||
|
||||
return current.map((message) => {
|
||||
const replacement = byTarget.get(message.id);
|
||||
if (!replacement) return message;
|
||||
return {
|
||||
...message,
|
||||
body: replacement.body ?? message.body,
|
||||
edited: true,
|
||||
timestamp: replacement.timestamp || message.timestamp,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function applyRedactions(
|
||||
current: MatrixMessage[],
|
||||
redactions: { redacts?: string; eventId?: string; targetEventId?: string }[] = []
|
||||
): MatrixMessage[] {
|
||||
if (!redactions.length) return current;
|
||||
const ids = new Set(redactions.map((item) => item.redacts || item.eventId || item.targetEventId).filter(Boolean));
|
||||
return current.filter((message) => !ids.has(message.id));
|
||||
}
|
||||
|
||||
function applyReactions(
|
||||
current: MatrixMessage[],
|
||||
reactions: { targetEventId?: string; key: string; count?: number; own?: boolean; senders?: string[] }[] = []
|
||||
): MatrixMessage[] {
|
||||
if (!reactions.length) return current;
|
||||
return current.map((message) => {
|
||||
const next = reactions.filter((reaction) => reaction.targetEventId === message.id);
|
||||
if (!next.length) return message;
|
||||
return { ...message, reactions: next };
|
||||
});
|
||||
}
|
||||
|
||||
export default function CommunicationScreen() {
|
||||
const { token } = useAuth();
|
||||
const messageListRef = useRef<FlatList<MatrixMessage>>(null);
|
||||
const [status, setStatus] = useState<MatrixStatus | null>(null);
|
||||
const [matrixUserId, setMatrixUserId] = useState('');
|
||||
const [rooms, setRooms] = useState<MatrixRoom[]>([]);
|
||||
const [members, setMembers] = useState<MatrixMember[]>([]);
|
||||
const [users, setUsers] = useState<MatrixUser[]>([]);
|
||||
const [messages, setMessages] = useState<MatrixMessage[]>([]);
|
||||
const [activeRoomKey, setActiveRoomKey] = useState('allgemein');
|
||||
const [syncSince, setSyncSince] = useState<string | undefined>();
|
||||
const [draft, setDraft] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [provisioning, setProvisioning] = useState(false);
|
||||
const [membersOpen, setMembersOpen] = useState(false);
|
||||
const [replyTarget, setReplyTarget] = useState<MatrixMessage | null>(null);
|
||||
const [editingMessage, setEditingMessage] = useState<MatrixMessage | null>(null);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
|
||||
const [roomName, setRoomName] = useState('');
|
||||
const [roomTopic, setRoomTopic] = useState('');
|
||||
const [roomKey, setRoomKey] = useState('');
|
||||
const [dialogText, setDialogText] = useState('');
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
|
||||
const activeRoom = useMemo(
|
||||
() => rooms.find((room) => room.key === activeRoomKey) || rooms[0] || null,
|
||||
[activeRoomKey, rooms]
|
||||
);
|
||||
|
||||
const visibleRooms = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
const base = [...rooms].sort((a, b) => {
|
||||
const groupCompare = String(a.group || '').localeCompare(String(b.group || ''));
|
||||
if (groupCompare !== 0) return groupCompare;
|
||||
return String(a.name || a.key).localeCompare(String(b.name || b.key));
|
||||
});
|
||||
if (!query) return base;
|
||||
return base.filter((room) =>
|
||||
[room.name, room.key, room.topic, room.email, room.projectNumber]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(query))
|
||||
);
|
||||
}, [rooms, search]);
|
||||
|
||||
const inviteCandidates = useMemo(() => {
|
||||
const existing = new Set(members.map((member) => member.matrixUserId));
|
||||
return users.filter((user) => !existing.has(user.matrixUserId));
|
||||
}, [members, users]);
|
||||
|
||||
const loadRooms = useCallback(async () => {
|
||||
if (!token) return;
|
||||
const [nextStatus, identity, nextRooms, nextUsers] = await Promise.all([
|
||||
fetchMatrixStatus(token),
|
||||
fetchMatrixIdentity(token),
|
||||
fetchMatrixRooms(token),
|
||||
fetchMatrixUsers(token),
|
||||
]);
|
||||
setStatus(nextStatus);
|
||||
setMatrixUserId(identity.matrixUserId || '');
|
||||
setRooms(nextRooms);
|
||||
|
||||
if (!nextRooms.some((room) => room.key === activeRoomKey) && nextRooms[0]) {
|
||||
setActiveRoomKey(nextRooms[0].key);
|
||||
}
|
||||
|
||||
setUsers(nextUsers);
|
||||
}, [activeRoomKey, token]);
|
||||
|
||||
const loadRoomContent = useCallback(
|
||||
async (roomKeyToLoad: string, showSpinner = true) => {
|
||||
if (!token || !roomKeyToLoad) return;
|
||||
if (showSpinner) setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [nextMessages, nextMembers, sync] = await Promise.all([
|
||||
fetchMatrixMessages(token, roomKeyToLoad),
|
||||
fetchMatrixMembers(token, roomKeyToLoad),
|
||||
syncMatrixRoom(token, roomKeyToLoad, undefined, true),
|
||||
]);
|
||||
const merged = mergeMessages(nextMessages, sync.messages || []);
|
||||
setMessages(sortMessages(merged));
|
||||
setMembers(sync.members || nextMembers);
|
||||
setSyncSince(sync.nextBatch);
|
||||
const last = merged.at(-1);
|
||||
if (last?.id) await markMatrixRoomRead(token, roomKeyToLoad, last.id);
|
||||
requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true }));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Kommunikation konnte nicht geladen werden.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
},
|
||||
[token]
|
||||
);
|
||||
|
||||
const refreshAll = useCallback(
|
||||
async (showSpinner = true) => {
|
||||
if (!token) return;
|
||||
if (showSpinner) setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await loadRooms();
|
||||
await loadRoomContent(activeRoomKey, false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Matrix-Kommunikation konnte nicht geladen werden.');
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
},
|
||||
[activeRoomKey, loadRoomContent, loadRooms, token]
|
||||
);
|
||||
|
||||
const pollSync = useCallback(async () => {
|
||||
if (!token || !activeRoom?.exists || !syncSince) return;
|
||||
try {
|
||||
const sync = await syncMatrixRoom(token, activeRoom.key, syncSince);
|
||||
setSyncSince(sync.nextBatch || syncSince);
|
||||
setMembers((current) => sync.members || current);
|
||||
setMessages((current) => {
|
||||
let next = mergeMessages(current, sync.messages || []);
|
||||
next = applyReplacements(next, sync.replacements);
|
||||
next = applyReactions(next, sync.reactions);
|
||||
next = applyRedactions(next, sync.redactions);
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// Polling errors are surfaced by manual refresh to avoid noisy chat usage.
|
||||
}
|
||||
}, [activeRoom, syncSince, token]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAll(true);
|
||||
}, [refreshAll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRoomKey) return;
|
||||
setReplyTarget(null);
|
||||
setEditingMessage(null);
|
||||
setMessages([]);
|
||||
setMembers([]);
|
||||
setSyncSince(undefined);
|
||||
void loadRoomContent(activeRoomKey, true);
|
||||
}, [activeRoomKey, loadRoomContent]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => void pollSync(), 5000);
|
||||
return () => clearInterval(id);
|
||||
}, [pollSync]);
|
||||
|
||||
async function ensureActiveRoom(): Promise<MatrixRoom | null> {
|
||||
if (!token || !activeRoom) return null;
|
||||
if (activeRoom.exists) return activeRoom;
|
||||
|
||||
setProvisioning(true);
|
||||
try {
|
||||
const room = await provisionMatrixRoom(token, activeRoom);
|
||||
setRooms((current) => current.map((item) => (item.key === activeRoom.key ? { ...item, ...room, exists: true } : item)));
|
||||
await loadRoomContent(activeRoom.key, false);
|
||||
return { ...activeRoom, ...room, exists: true };
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Raum konnte nicht bereitgestellt werden.');
|
||||
return null;
|
||||
} finally {
|
||||
setProvisioning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!token || !draft.trim()) return;
|
||||
const room = await ensureActiveRoom();
|
||||
if (!room) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const message = editingMessage
|
||||
? await editMatrixMessage(token, room.key, editingMessage.id, draft.trim())
|
||||
: await sendMatrixMessage(token, room.key, draft.trim(), replyTarget?.id);
|
||||
setMessages((current) =>
|
||||
editingMessage
|
||||
? current.map((item) => (item.id === editingMessage.id ? { ...item, ...message, edited: true } : item))
|
||||
: mergeMessages(current, [message])
|
||||
);
|
||||
setDraft('');
|
||||
setReplyTarget(null);
|
||||
setEditingMessage(null);
|
||||
requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true }));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Nachricht konnte nicht gesendet werden.');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickAttachment() {
|
||||
if (!token) return;
|
||||
const room = await ensureActiveRoom();
|
||||
if (!room) return;
|
||||
|
||||
const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false });
|
||||
if (result.canceled || !result.assets[0]) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const asset = result.assets[0];
|
||||
const message = await uploadMatrixAttachment(token, room.key, {
|
||||
uri: asset.uri,
|
||||
name: asset.name || 'Anhang',
|
||||
mimeType: asset.mimeType,
|
||||
});
|
||||
setMessages((current) => mergeMessages(current, [message]));
|
||||
requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true }));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Anhang konnte nicht hochgeladen werden.');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function provisionUser() {
|
||||
if (!token) return;
|
||||
setProvisioning(true);
|
||||
try {
|
||||
const identity = await provisionMatrixUser(token);
|
||||
setMatrixUserId(identity.matrixUserId || '');
|
||||
await refreshAll(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Matrix-Benutzer konnte nicht erstellt werden.');
|
||||
} finally {
|
||||
setProvisioning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createRoomFromDialog() {
|
||||
if (!token || !roomName.trim()) return;
|
||||
const key = roomKey.trim() || normalizeRoomKey(roomName);
|
||||
if (!key) return;
|
||||
|
||||
setProvisioning(true);
|
||||
try {
|
||||
const room = await createMatrixRoom(token, {
|
||||
key,
|
||||
name: roomName.trim(),
|
||||
topic: roomTopic.trim() || null,
|
||||
type: 'room',
|
||||
});
|
||||
setRooms((current) => [{ ...room, group: 'Räume', exists: true }, ...current.filter((item) => item.key !== key)]);
|
||||
setActiveRoomKey(key);
|
||||
setDialogMode(null);
|
||||
setRoomName('');
|
||||
setRoomTopic('');
|
||||
setRoomKey('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Raum konnte nicht erstellt werden.');
|
||||
} finally {
|
||||
setProvisioning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function inviteSelectedUser() {
|
||||
if (!token || !activeRoom || !selectedUserId) return;
|
||||
const room = await ensureActiveRoom();
|
||||
if (!room) return;
|
||||
|
||||
setProvisioning(true);
|
||||
try {
|
||||
await inviteMatrixMember(token, room.key, selectedUserId);
|
||||
setMembers(await fetchMatrixMembers(token, room.key));
|
||||
setDialogMode(null);
|
||||
setSelectedUserId('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Mitglied konnte nicht eingeladen werden.');
|
||||
} finally {
|
||||
setProvisioning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(message: MatrixMessage) {
|
||||
setEditingMessage(message);
|
||||
setDialogText(message.body || '');
|
||||
setDialogMode('edit-message');
|
||||
}
|
||||
|
||||
function confirmDelete(message: MatrixMessage) {
|
||||
Alert.alert('Nachricht löschen', 'Diese Nachricht wirklich entfernen?', [
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
if (!token || !activeRoom) return;
|
||||
void deleteMatrixMessage(token, activeRoom.key, message.id)
|
||||
.then(() => setMessages((current) => current.filter((item) => item.id !== message.id)))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Nachricht konnte nicht gelöscht werden.'));
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function confirmRemoveMember(member: MatrixMember) {
|
||||
Alert.alert('Mitglied entfernen', `${member.displayName || member.matrixUserId} aus dem Raum entfernen?`, [
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Entfernen',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
if (!token || !activeRoom) return;
|
||||
void removeMatrixMember(token, activeRoom.key, member.matrixUserId)
|
||||
.then(() => setMembers((current) => current.filter((item) => item.matrixUserId !== member.matrixUserId)))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Mitglied konnte nicht entfernt werden.'));
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async function addReaction(message: MatrixMessage, key: string) {
|
||||
if (!token || !activeRoom) return;
|
||||
try {
|
||||
await reactToMatrixMessage(token, activeRoom.key, message.id, key);
|
||||
await pollSync();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Reaktion konnte nicht gesendet werden.');
|
||||
}
|
||||
}
|
||||
|
||||
async function syncMembersNow() {
|
||||
if (!token || !activeRoom) return;
|
||||
setProvisioning(true);
|
||||
try {
|
||||
await syncMatrixMembers(token, activeRoom.key);
|
||||
setMembers(await fetchMatrixMembers(token, activeRoom.key));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Mitglieder konnten nicht synchronisiert werden.');
|
||||
} finally {
|
||||
setProvisioning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRoom({ item }: { item: MatrixRoom }) {
|
||||
const active = item.key === activeRoomKey;
|
||||
return (
|
||||
<Pressable style={[styles.roomChip, active && styles.roomChipActive]} onPress={() => setActiveRoomKey(item.key)}>
|
||||
<Text style={[styles.roomGroup, active && styles.roomTextActive]}>{item.group || 'Raum'}</Text>
|
||||
<Text style={[styles.roomName, active && styles.roomTextActive]} numberOfLines={1}>
|
||||
{item.name || item.key}
|
||||
</Text>
|
||||
<View style={styles.roomMetaRow}>
|
||||
{!item.exists ? <Text style={styles.roomBadgeMuted}>bereitstellen</Text> : null}
|
||||
{item.unread ? <Text style={styles.roomBadge}>{item.mentions ? `@${item.mentions}` : item.unread}</Text> : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function renderMessage({ item }: { item: MatrixMessage }) {
|
||||
const own = Boolean(item.own || item.sender === matrixUserId);
|
||||
const reply = item.replyToEventId ? messages.find((message) => message.id === item.replyToEventId) : null;
|
||||
|
||||
return (
|
||||
<View style={[styles.messageRow, own && styles.messageRowOwn]}>
|
||||
<View style={[styles.messageBubble, own && styles.messageBubbleOwn]}>
|
||||
<View style={styles.messageHeader}>
|
||||
<Text style={[styles.messageSender, own && styles.messageSenderOwn]} numberOfLines={1}>
|
||||
{own ? 'Du' : item.senderDisplayName || item.sender}
|
||||
</Text>
|
||||
<Text style={[styles.messageTime, own && styles.messageTimeOwn]}>{messageTime(item.timestamp)}</Text>
|
||||
</View>
|
||||
{reply ? (
|
||||
<View style={[styles.replyBox, own && styles.replyBoxOwn]}>
|
||||
<Text style={[styles.replyText, own && styles.replyTextOwn]} numberOfLines={2}>
|
||||
{reply.senderDisplayName || reply.sender}: {messagePreview(reply)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{item.body ? <Text style={[styles.messageBody, own && styles.messageBodyOwn]}>{item.body}</Text> : null}
|
||||
{item.attachment ? (
|
||||
<View style={[styles.attachmentBox, own && styles.attachmentBoxOwn]}>
|
||||
<Text style={[styles.attachmentTitle, own && styles.messageBodyOwn]} numberOfLines={1}>
|
||||
{item.attachment.fileName || 'Anhang'}
|
||||
</Text>
|
||||
<Text style={[styles.attachmentMeta, own && styles.messageTimeOwn]}>
|
||||
{item.attachment.mimeType || 'Datei'} {item.attachment.size ? `· ${Math.round(item.attachment.size / 1024)} KB` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{item.edited ? <Text style={[styles.editedText, own && styles.messageTimeOwn]}>bearbeitet</Text> : null}
|
||||
{item.reactions?.length ? (
|
||||
<View style={styles.reactionRow}>
|
||||
{item.reactions.map((reaction) => (
|
||||
<Text key={reaction.key} style={[styles.reactionPill, reaction.own && styles.reactionPillOwn]}>
|
||||
{reaction.key} {reaction.count || ''}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.messageActions}>
|
||||
<Pressable onPress={() => setReplyTarget(item)}>
|
||||
<Text style={[styles.actionText, own && styles.actionTextOwn]}>Antworten</Text>
|
||||
</Pressable>
|
||||
{REACTION_PRESETS.map((reaction) => (
|
||||
<Pressable key={reaction} onPress={() => addReaction(item, reaction)}>
|
||||
<Text style={[styles.actionText, own && styles.actionTextOwn]}>{reaction}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
{own && !item.attachment ? (
|
||||
<Pressable onPress={() => openEdit(item)}>
|
||||
<Text style={[styles.actionText, styles.actionTextOwn]}>Bearbeiten</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{own ? (
|
||||
<Pressable onPress={() => confirmDelete(item)}>
|
||||
<Text style={[styles.actionText, styles.actionTextDestructive]}>Löschen</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.screen}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerMain}>
|
||||
<Text style={styles.title}>Kommunikation</Text>
|
||||
<Text style={styles.subtitle} numberOfLines={1}>
|
||||
{activeRoom?.name || 'Matrix Chat'}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable style={styles.headerButton} onPress={() => setDialogMode('create-room')}>
|
||||
<Text style={styles.headerButtonText}>+</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.headerButton} onPress={() => setMembersOpen((value) => !value)}>
|
||||
<Text style={styles.headerButtonText}>☰</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
{status && status.enabled === false ? (
|
||||
<View style={styles.notice}>
|
||||
<Text style={styles.noticeTitle}>Matrix ist nicht aktiv</Text>
|
||||
<Text style={styles.noticeText}>Die Kommunikation ist serverseitig noch nicht aktiviert.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!matrixUserId ? (
|
||||
<View style={styles.notice}>
|
||||
<Text style={styles.noticeTitle}>Matrix-Benutzer fehlt</Text>
|
||||
<Text style={styles.noticeText}>Lege deinen Matrix-Zugang an, um Räume nutzen zu können.</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={provisionUser} disabled={provisioning}>
|
||||
<Text style={styles.primaryButtonText}>{provisioning ? 'Wird erstellt...' : 'Benutzer erstellen'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.roomPanel}>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Räume, Projekte oder Personen suchen"
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
/>
|
||||
<FlatList
|
||||
data={visibleRooms}
|
||||
horizontal
|
||||
keyExtractor={(item) => item.key}
|
||||
renderItem={renderRoom}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.roomList}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{membersOpen ? (
|
||||
<View style={styles.membersPanel}>
|
||||
<View style={styles.membersHeader}>
|
||||
<Text style={styles.membersTitle}>Mitglieder · {members.length}</Text>
|
||||
<View style={styles.membersActions}>
|
||||
<Pressable onPress={syncMembersNow}>
|
||||
<Text style={styles.linkText}>Sync</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setDialogMode('invite-member')}>
|
||||
<Text style={styles.linkText}>Einladen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.memberList}>
|
||||
{members.map((member) => (
|
||||
<Pressable key={member.matrixUserId} style={styles.memberPill} onLongPress={() => confirmRemoveMember(member)}>
|
||||
<Text style={styles.memberName}>{member.displayName || member.matrixUserId}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!activeRoom?.exists ? (
|
||||
<View style={styles.provisionPanel}>
|
||||
<Text style={styles.noticeTitle}>Raum noch nicht bereitgestellt</Text>
|
||||
<Text style={styles.noticeText}>Beim Öffnen wird der Matrix-Raum inklusive Einladungen angelegt.</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={ensureActiveRoom} disabled={provisioning}>
|
||||
<Text style={styles.primaryButtonText}>{provisioning ? 'Wird bereitgestellt...' : 'Raum bereitstellen'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator />
|
||||
<Text style={styles.loadingText}>Nachrichten werden geladen...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
ref={messageListRef}
|
||||
data={messages}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderMessage}
|
||||
contentContainerStyle={styles.messageList}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => {
|
||||
setRefreshing(true);
|
||||
void refreshAll(false);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={<Text style={styles.emptyText}>Noch keine Nachrichten in diesem Raum.</Text>}
|
||||
onContentSizeChange={() => messageListRef.current?.scrollToEnd({ animated: true })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(replyTarget || editingMessage) && !dialogMode ? (
|
||||
<View style={styles.composerContext}>
|
||||
<View style={styles.composerContextMain}>
|
||||
<Text style={styles.composerContextLabel}>{editingMessage ? 'Bearbeiten' : 'Antwort auf'}</Text>
|
||||
<Text style={styles.composerContextText} numberOfLines={1}>
|
||||
{messagePreview(editingMessage || replyTarget!)}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setReplyTarget(null);
|
||||
setEditingMessage(null);
|
||||
setDraft('');
|
||||
}}>
|
||||
<Text style={styles.linkText}>Abbrechen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.composer}>
|
||||
<Pressable style={styles.attachButton} onPress={pickAttachment} disabled={sending || !activeRoom}>
|
||||
<Text style={styles.attachButtonText}>+</Text>
|
||||
</Pressable>
|
||||
<TextInput
|
||||
style={styles.composerInput}
|
||||
placeholder="Nachricht schreiben"
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
multiline
|
||||
/>
|
||||
<Pressable style={[styles.sendButton, (!draft.trim() || sending) && styles.sendButtonDisabled]} onPress={sendMessage}>
|
||||
<Text style={styles.sendButtonText}>{sending ? '...' : 'Senden'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Modal transparent visible={Boolean(dialogMode)} animationType="fade" onRequestClose={() => setDialogMode(null)}>
|
||||
<View style={styles.modalBackdrop}>
|
||||
<View style={styles.modalCard}>
|
||||
{dialogMode === 'create-room' ? (
|
||||
<>
|
||||
<Text style={styles.modalTitle}>Raum erstellen</Text>
|
||||
<TextInput style={styles.modalInput} placeholder="Name" value={roomName} onChangeText={setRoomName} />
|
||||
<TextInput
|
||||
style={styles.modalInput}
|
||||
placeholder="Schlüssel, optional"
|
||||
value={roomKey}
|
||||
onChangeText={setRoomKey}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<TextInput style={styles.modalInput} placeholder="Thema, optional" value={roomTopic} onChangeText={setRoomTopic} />
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable onPress={() => setDialogMode(null)}>
|
||||
<Text style={styles.linkText}>Abbrechen</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.primaryButton} onPress={createRoomFromDialog}>
|
||||
<Text style={styles.primaryButtonText}>Erstellen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{dialogMode === 'edit-message' ? (
|
||||
<>
|
||||
<Text style={styles.modalTitle}>Nachricht bearbeiten</Text>
|
||||
<TextInput style={[styles.modalInput, styles.modalTextarea]} value={dialogText} onChangeText={setDialogText} multiline />
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable onPress={() => setDialogMode(null)}>
|
||||
<Text style={styles.linkText}>Abbrechen</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.primaryButton}
|
||||
onPress={() => {
|
||||
setDraft(dialogText);
|
||||
setDialogMode(null);
|
||||
}}>
|
||||
<Text style={styles.primaryButtonText}>Übernehmen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{dialogMode === 'invite-member' ? (
|
||||
<>
|
||||
<Text style={styles.modalTitle}>Mitglied einladen</Text>
|
||||
<ScrollView style={styles.inviteList}>
|
||||
{inviteCandidates.map((user) => (
|
||||
<Pressable
|
||||
key={user.userId}
|
||||
style={[styles.inviteRow, selectedUserId === user.userId && styles.inviteRowActive]}
|
||||
onPress={() => setSelectedUserId(user.userId)}>
|
||||
<Text style={styles.inviteName}>{user.displayName || user.email || user.userId}</Text>
|
||||
<Text style={styles.inviteMeta}>{user.matrixUserId}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
{!inviteCandidates.length ? <Text style={styles.emptyText}>Keine weiteren Benutzer verfügbar.</Text> : null}
|
||||
</ScrollView>
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable onPress={() => setDialogMode(null)}>
|
||||
<Text style={styles.linkText}>Abbrechen</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.primaryButton, !selectedUserId && styles.sendButtonDisabled]} onPress={inviteSelectedUser}>
|
||||
<Text style={styles.primaryButtonText}>Einladen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: '#f9fafb' },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#ffffff',
|
||||
borderBottomColor: '#e5e7eb',
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
headerMain: { flex: 1, minWidth: 0 },
|
||||
title: { color: '#111827', fontSize: 20, fontWeight: '800' },
|
||||
subtitle: { color: '#6b7280', fontSize: 13, marginTop: 2 },
|
||||
headerButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
headerButtonText: { color: '#111827', fontSize: 20, fontWeight: '700' },
|
||||
error: { margin: 12, color: '#991b1b', backgroundColor: '#fee2e2', borderRadius: 8, padding: 10 },
|
||||
notice: { margin: 12, padding: 12, borderRadius: 10, backgroundColor: '#fff7ed', borderWidth: 1, borderColor: '#fed7aa', gap: 8 },
|
||||
noticeTitle: { color: '#111827', fontWeight: '700', fontSize: 15 },
|
||||
noticeText: { color: '#6b7280', fontSize: 13 },
|
||||
roomPanel: { backgroundColor: '#ffffff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingVertical: 10 },
|
||||
searchInput: {
|
||||
marginHorizontal: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 9,
|
||||
fontSize: 14,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
roomList: { paddingHorizontal: 12, paddingTop: 10, gap: 8 },
|
||||
roomChip: { width: 150, padding: 10, borderRadius: 10, borderWidth: 1, borderColor: '#e5e7eb', backgroundColor: '#ffffff', gap: 3 },
|
||||
roomChipActive: { backgroundColor: '#eff9ea', borderColor: PRIMARY },
|
||||
roomGroup: { color: '#6b7280', fontSize: 11, textTransform: 'uppercase' },
|
||||
roomName: { color: '#111827', fontSize: 14, fontWeight: '700' },
|
||||
roomTextActive: { color: '#2f6f25' },
|
||||
roomMetaRow: { flexDirection: 'row', gap: 5, minHeight: 18 },
|
||||
roomBadge: { color: '#ffffff', backgroundColor: PRIMARY, borderRadius: 9, overflow: 'hidden', paddingHorizontal: 6, fontSize: 11 },
|
||||
roomBadgeMuted: { color: '#6b7280', backgroundColor: '#f3f4f6', borderRadius: 9, overflow: 'hidden', paddingHorizontal: 6, fontSize: 11 },
|
||||
membersPanel: { backgroundColor: '#ffffff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb', padding: 12, gap: 8 },
|
||||
membersHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||
membersTitle: { color: '#111827', fontWeight: '700' },
|
||||
membersActions: { flexDirection: 'row', gap: 16 },
|
||||
memberList: { gap: 8 },
|
||||
memberPill: { backgroundColor: '#f3f4f6', paddingHorizontal: 10, paddingVertical: 7, borderRadius: 16 },
|
||||
memberName: { color: '#374151', fontSize: 12 },
|
||||
provisionPanel: { margin: 12, padding: 12, borderRadius: 10, backgroundColor: '#ffffff', borderWidth: 1, borderColor: '#e5e7eb', gap: 8 },
|
||||
loading: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8 },
|
||||
loadingText: { color: '#6b7280' },
|
||||
messageList: { padding: 12, gap: 10, flexGrow: 1 },
|
||||
emptyText: { color: '#6b7280', textAlign: 'center', padding: 18 },
|
||||
messageRow: { alignItems: 'flex-start' },
|
||||
messageRowOwn: { alignItems: 'flex-end' },
|
||||
messageBubble: {
|
||||
maxWidth: '88%',
|
||||
minWidth: 120,
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
padding: 10,
|
||||
gap: 6,
|
||||
},
|
||||
messageBubbleOwn: { backgroundColor: '#3f8f32', borderColor: '#3f8f32' },
|
||||
messageHeader: { flexDirection: 'row', justifyContent: 'space-between', gap: 10 },
|
||||
messageSender: { flex: 1, color: '#111827', fontSize: 12, fontWeight: '700' },
|
||||
messageSenderOwn: { color: '#ffffff' },
|
||||
messageTime: { color: '#6b7280', fontSize: 11 },
|
||||
messageTimeOwn: { color: '#dff4d9' },
|
||||
messageBody: { color: '#111827', fontSize: 15, lineHeight: 21 },
|
||||
messageBodyOwn: { color: '#ffffff' },
|
||||
replyBox: { backgroundColor: '#f3f4f6', borderRadius: 8, borderLeftWidth: 3, borderLeftColor: PRIMARY, padding: 7 },
|
||||
replyBoxOwn: { backgroundColor: '#327628', borderLeftColor: '#dff4d9' },
|
||||
replyText: { color: '#4b5563', fontSize: 12 },
|
||||
replyTextOwn: { color: '#dff4d9' },
|
||||
attachmentBox: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, padding: 8, gap: 2 },
|
||||
attachmentBoxOwn: { borderColor: '#dff4d9' },
|
||||
attachmentTitle: { color: '#111827', fontWeight: '700' },
|
||||
attachmentMeta: { color: '#6b7280', fontSize: 12 },
|
||||
editedText: { color: '#6b7280', fontSize: 11 },
|
||||
reactionRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 5 },
|
||||
reactionPill: { backgroundColor: '#f3f4f6', borderRadius: 12, overflow: 'hidden', paddingHorizontal: 7, paddingVertical: 2, fontSize: 12 },
|
||||
reactionPillOwn: { backgroundColor: '#dff4d9' },
|
||||
messageActions: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingTop: 2 },
|
||||
actionText: { color: '#3f8f32', fontSize: 12, fontWeight: '700' },
|
||||
actionTextOwn: { color: '#ffffff' },
|
||||
actionTextDestructive: { color: '#fee2e2' },
|
||||
composerContext: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: '#ffffff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e5e7eb',
|
||||
},
|
||||
composerContextMain: { flex: 1, minWidth: 0 },
|
||||
composerContextLabel: { color: '#6b7280', fontSize: 11, textTransform: 'uppercase' },
|
||||
composerContextText: { color: '#111827', fontSize: 13, fontWeight: '600' },
|
||||
composer: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, padding: 10, backgroundColor: '#ffffff', borderTopWidth: 1, borderTopColor: '#e5e7eb' },
|
||||
attachButton: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', backgroundColor: '#f3f4f6' },
|
||||
attachButtonText: { color: '#111827', fontSize: 22, fontWeight: '700' },
|
||||
composerInput: { flex: 1, maxHeight: 110, borderRadius: 18, backgroundColor: '#f3f4f6', paddingHorizontal: 12, paddingVertical: 10, fontSize: 15 },
|
||||
sendButton: { minWidth: 70, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', backgroundColor: PRIMARY, paddingHorizontal: 12 },
|
||||
sendButtonDisabled: { opacity: 0.45 },
|
||||
sendButtonText: { color: '#ffffff', fontWeight: '800' },
|
||||
primaryButton: { alignSelf: 'flex-start', backgroundColor: PRIMARY, borderRadius: 9, paddingHorizontal: 12, paddingVertical: 9 },
|
||||
primaryButtonText: { color: '#ffffff', fontWeight: '800' },
|
||||
linkText: { color: '#3f8f32', fontWeight: '800' },
|
||||
modalBackdrop: { flex: 1, backgroundColor: 'rgba(17, 24, 39, 0.45)', alignItems: 'center', justifyContent: 'center', padding: 18 },
|
||||
modalCard: { width: '100%', maxWidth: 420, borderRadius: 12, backgroundColor: '#ffffff', padding: 16, gap: 12 },
|
||||
modalTitle: { color: '#111827', fontSize: 18, fontWeight: '800' },
|
||||
modalInput: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 9, paddingHorizontal: 12, paddingVertical: 10, fontSize: 15 },
|
||||
modalTextarea: { minHeight: 110, textAlignVertical: 'top' },
|
||||
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 14 },
|
||||
inviteList: { maxHeight: 280 },
|
||||
inviteRow: { padding: 10, borderRadius: 9, borderWidth: 1, borderColor: '#e5e7eb', marginBottom: 8 },
|
||||
inviteRowActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' },
|
||||
inviteName: { color: '#111827', fontWeight: '700' },
|
||||
inviteMeta: { color: '#6b7280', fontSize: 12, marginTop: 2 },
|
||||
});
|
||||
@@ -18,7 +18,6 @@ export default function RootLayout() {
|
||||
title: 'Projekt',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerBackTitleVisible: false,
|
||||
headerTintColor: '#111827',
|
||||
}}
|
||||
/>
|
||||
@@ -28,7 +27,6 @@ export default function RootLayout() {
|
||||
title: 'Konto',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerBackTitleVisible: false,
|
||||
headerTintColor: '#111827',
|
||||
}}
|
||||
/>
|
||||
@@ -38,7 +36,6 @@ export default function RootLayout() {
|
||||
title: 'Einstellungen',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerBackTitleVisible: false,
|
||||
headerTintColor: '#111827',
|
||||
}}
|
||||
/>
|
||||
@@ -48,7 +45,6 @@ export default function RootLayout() {
|
||||
title: 'Wiki',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerBackTitleVisible: false,
|
||||
headerTintColor: '#111827',
|
||||
}}
|
||||
/>
|
||||
@@ -58,7 +54,6 @@ export default function RootLayout() {
|
||||
title: 'Kunden',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerBackTitleVisible: false,
|
||||
headerTintColor: '#111827',
|
||||
}}
|
||||
/>
|
||||
@@ -68,7 +63,6 @@ export default function RootLayout() {
|
||||
title: 'Kunde',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerBackTitleVisible: false,
|
||||
headerTintColor: '#111827',
|
||||
}}
|
||||
/>
|
||||
@@ -78,7 +72,6 @@ export default function RootLayout() {
|
||||
title: 'Objekte',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerBackTitleVisible: false,
|
||||
headerTintColor: '#111827',
|
||||
}}
|
||||
/>
|
||||
@@ -88,7 +81,6 @@ export default function RootLayout() {
|
||||
title: 'Objekt',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerBackTitleVisible: false,
|
||||
headerTintColor: '#111827',
|
||||
}}
|
||||
/>
|
||||
@@ -98,7 +90,6 @@ export default function RootLayout() {
|
||||
title: 'Kundeninventar',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerBackTitleVisible: false,
|
||||
headerTintColor: '#111827',
|
||||
}}
|
||||
/>
|
||||
@@ -108,7 +99,6 @@ export default function RootLayout() {
|
||||
title: 'Nimbot M2',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerBackTitleVisible: false,
|
||||
headerTintColor: '#111827',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,11 @@ type IconSymbolName = keyof typeof MAPPING;
|
||||
const MAPPING = {
|
||||
'house.fill': 'home',
|
||||
'paperplane.fill': 'send',
|
||||
'message.fill': 'chat',
|
||||
'folder.fill': 'folder',
|
||||
checklist: 'checklist',
|
||||
'clock.fill': 'schedule',
|
||||
'ellipsis.circle.fill': 'more-horiz',
|
||||
'chevron.left.forwardslash.chevron.right': 'code',
|
||||
'chevron.right': 'chevron-right',
|
||||
} as IconMapping;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"node": "22.22.3",
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
@@ -11,13 +12,35 @@
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"node": "22.22.3",
|
||||
"distribution": "internal"
|
||||
},
|
||||
"testflight": {
|
||||
"node": "22.22.3",
|
||||
"distribution": "store",
|
||||
"autoIncrement": true,
|
||||
"env": {
|
||||
"EXPO_PUBLIC_API_BASE": "https://app.fedeo.de/backend"
|
||||
},
|
||||
"ios": {
|
||||
"simulator": false
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"node": "22.22.3",
|
||||
"distribution": "store",
|
||||
"env": {
|
||||
"EXPO_PUBLIC_API_BASE": "https://app.fedeo.de/backend"
|
||||
},
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
"testflight": {
|
||||
"ios": {}
|
||||
},
|
||||
"production": {
|
||||
"ios": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
397
mobile/package-lock.json
generated
397
mobile/package-lock.json
generated
@@ -1,33 +1,33 @@
|
||||
{
|
||||
"name": "mobile",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mobile",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"expo": "~54.0.33",
|
||||
"expo": "~54.0.34",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-document-picker": "^14.0.8",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-picker": "~17.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-image-picker": "~17.0.11",
|
||||
"expo-linking": "~8.0.12",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-secure-store": "^15.0.8",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-symbols": "~1.0.8",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"expo-web-browser": "~15.0.11",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
@@ -37,7 +37,7 @@
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
"react-native-webview": "13.15.0",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -45,6 +45,9 @@
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "22.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@0no-co/graphql.web": {
|
||||
@@ -1873,9 +1876,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/fingerprint": {
|
||||
"version": "0.15.4",
|
||||
"resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.4.tgz",
|
||||
"integrity": "sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng==",
|
||||
"version": "0.15.5",
|
||||
"resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.5.tgz",
|
||||
"integrity": "sha512-mdVoAMcux1WlM6kd1RoWiHRNqKqS+J6mKmWQ/BKgeh937S/fcW58EE68O6nc4KDXtWi3PBeNHskOFcgyIuD4hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
@@ -1885,7 +1888,7 @@
|
||||
"getenv": "^2.0.0",
|
||||
"glob": "^13.0.0",
|
||||
"ignore": "^5.3.1",
|
||||
"minimatch": "^9.0.0",
|
||||
"minimatch": "^10.2.2",
|
||||
"p-limit": "^3.1.0",
|
||||
"resolve-from": "^5.0.0",
|
||||
"semver": "^7.6.0"
|
||||
@@ -1894,34 +1897,46 @@
|
||||
"fingerprint": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/fingerprint/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/fingerprint/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/fingerprint/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"license": "ISC",
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/fingerprint/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -1961,24 +1976,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/json-file": {
|
||||
"version": "10.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz",
|
||||
"integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==",
|
||||
"version": "10.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.14.tgz",
|
||||
"integrity": "sha512-yWwBFywFv+SxkJp/pIzzA416JVYflNUh7pqQzgaA6nXDqRyK7KfrqVzk8PdUfDnqbBcaZZxpzNssfQZzp5KHrA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "~7.10.4",
|
||||
"@babel/code-frame": "^7.20.0",
|
||||
"json5": "^2.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/json-file/node_modules/@babel/code-frame": {
|
||||
"version": "7.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
|
||||
"integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/highlight": "^7.10.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/metro": {
|
||||
"version": "54.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz",
|
||||
@@ -2002,9 +2008,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/metro-config": {
|
||||
"version": "54.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.14.tgz",
|
||||
"integrity": "sha512-hxpLyDfOR4L23tJ9W1IbJJsG7k4lv2sotohBm/kTYyiG+pe1SYCAWsRmgk+H42o/wWf/HQjE5k45S5TomGLxNA==",
|
||||
"version": "54.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.15.tgz",
|
||||
"integrity": "sha512-SqIya4VZ9KHM1S9g+xR0A+QKw1Tfs7Gacx6bQNJ98vs4+O7I5+QP5mHZIB0QSZLUV8opiXebHYTiTu+0OAsIUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.20.0",
|
||||
@@ -2025,7 +2031,7 @@
|
||||
"hermes-parser": "^0.29.1",
|
||||
"jsc-safe-url": "^0.2.4",
|
||||
"lightningcss": "^1.30.1",
|
||||
"minimatch": "^9.0.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "~8.4.32",
|
||||
"resolve-from": "^5.0.0"
|
||||
},
|
||||
@@ -2038,28 +2044,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/metro-config/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"node_modules/@expo/metro-config/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/metro-config/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/metro-runtime": {
|
||||
@@ -2086,25 +2080,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/osascript": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.3.8.tgz",
|
||||
"integrity": "sha512-/TuOZvSG7Nn0I8c+FcEaoHeBO07yu6vwDgk7rZVvAXoeAK5rkA09jRyjYsZo+0tMEFaToBeywA6pj50Mb3ny9w==",
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.4.3.tgz",
|
||||
"integrity": "sha512-wbuj3EebM7W9hN/Wp4xTzKd6rQ2zKJzAxkFxkOOwyysLp0HOAgQ4/5RINyoS241pZUX2rUHq7mAJ7pcCQ8U0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"exec-async": "^2.2.0"
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/package-manager": {
|
||||
"version": "1.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.9.10.tgz",
|
||||
"integrity": "sha512-axJm+NOj3jVxep49va/+L3KkF3YW/dkV+RwzqUJedZrv4LeTqOG4rhrCaCPXHTvLqCTDKu6j0Xyd28N7mnxsGA==",
|
||||
"version": "1.10.5",
|
||||
"resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.10.5.tgz",
|
||||
"integrity": "sha512-nCP9Mebfl3jvOr0/P6VAuyah6PAtun+aihIL2zAtuE8uSe94JWkVZ7051i0MUVO+y3gFpBqnr8IIH5ch+VJjHA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/json-file": "^10.0.8",
|
||||
"@expo/json-file": "^10.0.14",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"chalk": "^4.0.0",
|
||||
"npm-package-arg": "^11.0.0",
|
||||
@@ -2204,9 +2197,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@expo/xcpretty": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.0.tgz",
|
||||
"integrity": "sha512-o2qDlTqJ606h4xR36H2zWTywmZ3v3842K6TU8Ik2n1mfW0S580VHlt3eItVYdLYz+klaPp7CXqanja8eASZjRw==",
|
||||
"version": "4.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz",
|
||||
"integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.20.0",
|
||||
@@ -5998,36 +5991,30 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/exec-async": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz",
|
||||
"integrity": "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expo": {
|
||||
"version": "54.0.33",
|
||||
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz",
|
||||
"integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==",
|
||||
"version": "54.0.34",
|
||||
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.34.tgz",
|
||||
"integrity": "sha512-XkVHguZZDC8BcTQxHAd14/TQFbDp1Wt0Z/KApO9t68Ll5A127hLCPzU+a9gytfCIiyL/V1IpF1vIcOLKEVAoNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@expo/cli": "54.0.23",
|
||||
"@expo/cli": "54.0.24",
|
||||
"@expo/config": "~12.0.13",
|
||||
"@expo/config-plugins": "~54.0.4",
|
||||
"@expo/devtools": "0.1.8",
|
||||
"@expo/fingerprint": "0.15.4",
|
||||
"@expo/fingerprint": "0.15.5",
|
||||
"@expo/metro": "~54.2.0",
|
||||
"@expo/metro-config": "54.0.14",
|
||||
"@expo/metro-config": "54.0.15",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@ungap/structured-clone": "^1.3.0",
|
||||
"babel-preset-expo": "~54.0.10",
|
||||
"expo-asset": "~12.0.12",
|
||||
"expo-asset": "~12.0.13",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-file-system": "~19.0.22",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-keep-awake": "~15.0.8",
|
||||
"expo-modules-autolinking": "3.0.24",
|
||||
"expo-modules-core": "3.0.29",
|
||||
"expo-modules-autolinking": "3.0.25",
|
||||
"expo-modules-core": "3.0.30",
|
||||
"pretty-format": "^29.7.0",
|
||||
"react-refresh": "^0.14.2",
|
||||
"whatwg-url-without-unicode": "8.0.0-3"
|
||||
@@ -6057,13 +6044,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-asset": {
|
||||
"version": "12.0.12",
|
||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz",
|
||||
"integrity": "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==",
|
||||
"version": "12.0.13",
|
||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz",
|
||||
"integrity": "sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/image-utils": "^0.8.8",
|
||||
"expo-constants": "~18.0.12"
|
||||
"expo-constants": "~18.0.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
@@ -6115,9 +6102,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-file-system": {
|
||||
"version": "19.0.21",
|
||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
||||
"integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==",
|
||||
"version": "19.0.22",
|
||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz",
|
||||
"integrity": "sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
@@ -6174,9 +6161,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-picker": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
|
||||
"integrity": "sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==",
|
||||
"version": "17.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.11.tgz",
|
||||
"integrity": "sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expo-image-loader": "~6.0.0"
|
||||
@@ -6196,12 +6183,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-linking": {
|
||||
"version": "8.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz",
|
||||
"integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==",
|
||||
"version": "8.0.12",
|
||||
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz",
|
||||
"integrity": "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expo-constants": "~18.0.12",
|
||||
"expo-constants": "~18.0.13",
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -6210,9 +6197,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-autolinking": {
|
||||
"version": "3.0.24",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
|
||||
"integrity": "sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ==",
|
||||
"version": "3.0.25",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz",
|
||||
"integrity": "sha512-YmHWctJlwvOuLZccg3cOXvSiXVJrPMKl7g2YR0YHWoGL9v2RvcmgaPJWPSLVW+voNEgEPsbo5UmUrAqbnYcBeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
@@ -6226,9 +6213,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-core": {
|
||||
"version": "3.0.29",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.29.tgz",
|
||||
"integrity": "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q==",
|
||||
"version": "3.0.30",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.30.tgz",
|
||||
"integrity": "sha512-a6IrpAn/Jbmwxi9L+hMmXKpNqnkUpoF7WHOpn02rVLyax2J0gB1vvCVE5rNydplEnt41Q6WxQwvcOjZaIkcSUg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4"
|
||||
@@ -6501,9 +6488,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-server": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
|
||||
"integrity": "sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==",
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.6.tgz",
|
||||
"integrity": "sha512-vb5TBtskvEdzYuW79lATXutOEBfW5m6U4EFpNjCVZTnI7S//SAsLQkYEpn+EDfn84m6VQfzSGkIVR6YPaScKFA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.16.0"
|
||||
@@ -6568,9 +6555,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-web-browser": {
|
||||
"version": "15.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz",
|
||||
"integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==",
|
||||
"version": "15.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz",
|
||||
"integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
@@ -6578,9 +6565,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/@expo/cli": {
|
||||
"version": "54.0.23",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz",
|
||||
"integrity": "sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g==",
|
||||
"version": "54.0.24",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.24.tgz",
|
||||
"integrity": "sha512-5xse1bEgnVUBhOrtttc6xTNJVvjyTRavpzuF0/0nuj+312vfSbk7EiRbG+xJ2pW/iZxnhLPJkFCrPYG0nmheAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@0no-co/graphql.web": "^1.0.8",
|
||||
@@ -6592,7 +6579,7 @@
|
||||
"@expo/image-utils": "^0.8.8",
|
||||
"@expo/json-file": "^10.0.8",
|
||||
"@expo/metro": "~54.2.0",
|
||||
"@expo/metro-config": "~54.0.14",
|
||||
"@expo/metro-config": "~54.0.15",
|
||||
"@expo/osascript": "^2.3.8",
|
||||
"@expo/package-manager": "^1.9.10",
|
||||
"@expo/plist": "^0.4.8",
|
||||
@@ -6615,16 +6602,16 @@
|
||||
"connect": "^3.7.0",
|
||||
"debug": "^4.3.4",
|
||||
"env-editor": "^0.4.1",
|
||||
"expo-server": "^1.0.5",
|
||||
"expo-server": "^1.0.6",
|
||||
"freeport-async": "^2.0.0",
|
||||
"getenv": "^2.0.0",
|
||||
"glob": "^13.0.0",
|
||||
"lan-network": "^0.1.6",
|
||||
"lan-network": "^0.2.1",
|
||||
"minimatch": "^9.0.0",
|
||||
"node-forge": "^1.3.3",
|
||||
"npm-package-arg": "^11.0.0",
|
||||
"ora": "^3.4.0",
|
||||
"picomatch": "^3.0.1",
|
||||
"picomatch": "^4.0.3",
|
||||
"pretty-bytes": "^5.6.0",
|
||||
"pretty-format": "^29.7.0",
|
||||
"progress": "^2.0.3",
|
||||
@@ -6665,9 +6652,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -6689,12 +6676,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -6704,21 +6691,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/picomatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz",
|
||||
"integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -6728,9 +6715,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -8394,9 +8381,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lan-network": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.1.7.tgz",
|
||||
"integrity": "sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==",
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz",
|
||||
"integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lan-network": "dist/lan-network-cli.js"
|
||||
@@ -8451,9 +8438,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
@@ -8466,23 +8453,23 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.31.1",
|
||||
"lightningcss-darwin-arm64": "1.31.1",
|
||||
"lightningcss-darwin-x64": "1.31.1",
|
||||
"lightningcss-freebsd-x64": "1.31.1",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.31.1",
|
||||
"lightningcss-linux-arm64-gnu": "1.31.1",
|
||||
"lightningcss-linux-arm64-musl": "1.31.1",
|
||||
"lightningcss-linux-x64-gnu": "1.31.1",
|
||||
"lightningcss-linux-x64-musl": "1.31.1",
|
||||
"lightningcss-win32-arm64-msvc": "1.31.1",
|
||||
"lightningcss-win32-x64-msvc": "1.31.1"
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
|
||||
"integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8500,9 +8487,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
|
||||
"integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8520,9 +8507,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
|
||||
"integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8540,9 +8527,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
|
||||
"integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8560,9 +8547,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
|
||||
"integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -8580,9 +8567,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
|
||||
"integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8600,9 +8587,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
|
||||
"integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8620,9 +8607,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
|
||||
"integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8640,9 +8627,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
|
||||
"integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8660,9 +8647,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
|
||||
"integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8680,9 +8667,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
|
||||
"integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -9402,9 +9389,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
|
||||
"integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
@@ -9447,9 +9434,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/npm-package-arg/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -10453,9 +10440,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-native-webview": {
|
||||
"version": "13.16.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.0.tgz",
|
||||
"integrity": "sha512-Nh13xKZWW35C0dbOskD7OX01nQQavOzHbCw9XoZmar4eXCo7AvrYJ0jlUfRVVIJzqINxHlpECYLdmAdFsl9xDA==",
|
||||
"version": "13.15.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz",
|
||||
"integrity": "sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
@@ -11722,9 +11709,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.9",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
|
||||
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
|
||||
"version": "7.5.15",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
||||
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
@@ -12155,9 +12142,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.23.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||
"version": "6.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
|
||||
"integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
@@ -12772,9 +12759,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wonka": {
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz",
|
||||
"integrity": "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw==",
|
||||
"version": "6.3.6",
|
||||
"resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz",
|
||||
"integrity": "sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"name": "mobile",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"engines": {
|
||||
"node": "22.x"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "expo start --dev-client --host lan",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
@@ -10,30 +13,34 @@
|
||||
"ios:device": "LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 expo run:ios --device",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint",
|
||||
"doctor": "npx expo-doctor",
|
||||
"preflight:testflight": "npm run lint && npm run doctor",
|
||||
"build:ios:dev": "eas build --profile development --platform ios",
|
||||
"build:ios:preview": "eas build --profile preview --platform ios"
|
||||
"build:ios:preview": "eas build --profile preview --platform ios",
|
||||
"build:ios:testflight": "eas build --profile testflight --platform ios",
|
||||
"submit:ios:testflight": "eas submit --profile testflight --platform ios --latest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"expo": "~54.0.33",
|
||||
"expo": "~54.0.34",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-document-picker": "^14.0.8",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-picker": "~17.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-image-picker": "~17.0.11",
|
||||
"expo-linking": "~8.0.12",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-secure-store": "^15.0.8",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-symbols": "~1.0.8",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"expo-web-browser": "~15.0.11",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
@@ -43,7 +50,7 @@
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
"react-native-webview": "13.15.0",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
BIN
mobile/screenshots/vertrieb/01-dashboard.png
Normal file
BIN
mobile/screenshots/vertrieb/01-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
BIN
mobile/screenshots/vertrieb/02-dashboard-content.png
Normal file
BIN
mobile/screenshots/vertrieb/02-dashboard-content.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
BIN
mobile/screenshots/vertrieb/03-dashboard-kompakt.png
Normal file
BIN
mobile/screenshots/vertrieb/03-dashboard-kompakt.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
@@ -183,6 +183,100 @@ type RequestOptions = {
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
export type MatrixStatus = {
|
||||
enabled?: boolean;
|
||||
ready?: boolean;
|
||||
configured?: boolean;
|
||||
homeserverUrl?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MatrixIdentity = {
|
||||
matrixUserId: string;
|
||||
displayName?: string | null;
|
||||
};
|
||||
|
||||
export type MatrixRoom = {
|
||||
key: string;
|
||||
name: string;
|
||||
topic?: string | null;
|
||||
type?: 'room' | 'project' | 'direct' | string;
|
||||
group?: string;
|
||||
roomId?: string | null;
|
||||
alias?: string | null;
|
||||
exists?: boolean;
|
||||
projectId?: number;
|
||||
projectNumber?: string | null;
|
||||
userId?: string;
|
||||
email?: string | null;
|
||||
entityType?: string | null;
|
||||
entityId?: number | null;
|
||||
entityUuid?: string | null;
|
||||
unread?: number;
|
||||
mentions?: number;
|
||||
provisionEndpoint?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MatrixAttachment = {
|
||||
fileName?: string | null;
|
||||
mimeType?: string | null;
|
||||
size?: number | null;
|
||||
mxcUri?: string | null;
|
||||
previewUrl?: string | null;
|
||||
downloadUrl?: string | null;
|
||||
};
|
||||
|
||||
export type MatrixReaction = {
|
||||
key: string;
|
||||
count?: number;
|
||||
own?: boolean;
|
||||
senders?: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MatrixMessage = {
|
||||
id: string;
|
||||
sender: string;
|
||||
senderDisplayName?: string | null;
|
||||
body?: string | null;
|
||||
timestamp?: string | number | null;
|
||||
own?: boolean;
|
||||
edited?: boolean;
|
||||
redacted?: boolean;
|
||||
msgtype?: string;
|
||||
attachment?: MatrixAttachment | null;
|
||||
replyToEventId?: string | null;
|
||||
reactions?: MatrixReaction[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MatrixMember = {
|
||||
matrixUserId: string;
|
||||
displayName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
membership?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MatrixUser = {
|
||||
userId: string;
|
||||
matrixUserId: string;
|
||||
displayName?: string | null;
|
||||
email?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MatrixSyncResponse = {
|
||||
nextBatch?: string;
|
||||
messages?: MatrixMessage[];
|
||||
replacements?: MatrixMessage[];
|
||||
reactions?: (MatrixReaction & { targetEventId?: string })[];
|
||||
redactions?: { redacts?: string; eventId?: string; targetEventId?: string }[];
|
||||
members?: MatrixMember[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function buildUrl(path: string): string {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
@@ -227,10 +321,231 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
async function apiFormRequest<T>(path: string, token: string, formData: FormData): Promise<T> {
|
||||
const response = await fetch(buildUrl(path), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const payload = await parseJson(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(payload as { message?: string; error?: string } | null)?.message ||
|
||||
(payload as { message?: string; error?: string } | null)?.error ||
|
||||
`Request failed (${response.status}) for ${path}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function matrixRoomPath(roomKey: string, suffix = ''): string {
|
||||
return `/api/communication/matrix/rooms/${encodeURIComponent(roomKey)}${suffix}`;
|
||||
}
|
||||
|
||||
export async function checkBackendHealth(): Promise<{ status: string; [key: string]: unknown }> {
|
||||
return apiRequest<{ status: string; [key: string]: unknown }>('/health');
|
||||
}
|
||||
|
||||
export async function fetchMatrixStatus(token: string): Promise<MatrixStatus> {
|
||||
return apiRequest<MatrixStatus>('/api/communication/matrix/status', { token });
|
||||
}
|
||||
|
||||
export async function fetchMatrixIdentity(token: string): Promise<MatrixIdentity> {
|
||||
return apiRequest<MatrixIdentity>('/api/communication/matrix/me', { token });
|
||||
}
|
||||
|
||||
export async function provisionMatrixUser(token: string): Promise<MatrixIdentity> {
|
||||
return apiRequest<MatrixIdentity>('/api/communication/matrix/me/provision', {
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMatrixRooms(token: string): Promise<MatrixRoom[]> {
|
||||
const [rooms, projectRooms, directRooms, unread] = await Promise.all([
|
||||
apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/rooms', { token }),
|
||||
apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/project-rooms', { token }),
|
||||
apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/direct-rooms', { token }),
|
||||
apiRequest<{ rooms?: Record<string, { count?: number; mentions?: number }> }>('/api/communication/matrix/unread', {
|
||||
token,
|
||||
}),
|
||||
]);
|
||||
|
||||
const unreadByRoom = unread.rooms || {};
|
||||
const decorate = (room: MatrixRoom, group: string): MatrixRoom => ({
|
||||
...room,
|
||||
group,
|
||||
unread: unreadByRoom[room.key]?.count || 0,
|
||||
mentions: unreadByRoom[room.key]?.mentions || 0,
|
||||
});
|
||||
|
||||
return [
|
||||
...(rooms.rooms || []).map((room) => decorate(room, 'Räume')),
|
||||
...(projectRooms.rooms || []).map((room) => decorate(room, 'Projekte')),
|
||||
...(directRooms.rooms || []).map((room) => decorate(room, 'Direkt')),
|
||||
];
|
||||
}
|
||||
|
||||
export async function fetchMatrixUsers(token: string): Promise<MatrixUser[]> {
|
||||
const response = await apiRequest<{ users?: MatrixUser[] }>('/api/communication/matrix/users', { token });
|
||||
return response.users || [];
|
||||
}
|
||||
|
||||
export async function createMatrixRoom(
|
||||
token: string,
|
||||
payload: { key: string; name: string; topic?: string | null; type?: string }
|
||||
): Promise<MatrixRoom> {
|
||||
return apiRequest<MatrixRoom>('/api/communication/matrix/rooms', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function provisionMatrixRoom(token: string, room: MatrixRoom): Promise<MatrixRoom> {
|
||||
if (room.provisionEndpoint) {
|
||||
return apiRequest<MatrixRoom>(room.provisionEndpoint, { method: 'POST', token });
|
||||
}
|
||||
|
||||
if (room.type === 'project' && room.projectId) {
|
||||
return apiRequest<MatrixRoom>(`/api/communication/matrix/project-rooms/${room.projectId}/provision`, {
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
if (room.type === 'direct' && room.userId) {
|
||||
return apiRequest<MatrixRoom>(`/api/communication/matrix/direct-rooms/${encodeURIComponent(room.userId)}/provision`, {
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
return apiRequest<MatrixRoom>(matrixRoomPath(room.key, '/provision'), {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
key: room.key,
|
||||
name: room.name,
|
||||
topic: room.topic,
|
||||
type: room.type || 'room',
|
||||
entityType: room.entityType,
|
||||
entityId: room.entityId,
|
||||
entityUuid: room.entityUuid,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMatrixMessages(token: string, roomKey: string): Promise<MatrixMessage[]> {
|
||||
const response = await apiRequest<{ messages?: MatrixMessage[] }>(matrixRoomPath(roomKey, '/messages'), { token });
|
||||
return response.messages || [];
|
||||
}
|
||||
|
||||
export async function syncMatrixRoom(
|
||||
token: string,
|
||||
roomKey: string,
|
||||
since?: string,
|
||||
initial = false
|
||||
): Promise<MatrixSyncResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (since) query.set('since', since);
|
||||
if (initial) query.set('initial', '1');
|
||||
const suffix = query.toString() ? `/sync?${query.toString()}` : '/sync';
|
||||
return apiRequest<MatrixSyncResponse>(matrixRoomPath(roomKey, suffix), { token });
|
||||
}
|
||||
|
||||
export async function fetchMatrixMembers(token: string, roomKey: string): Promise<MatrixMember[]> {
|
||||
const response = await apiRequest<{ members?: MatrixMember[] }>(matrixRoomPath(roomKey, '/members'), { token });
|
||||
return response.members || [];
|
||||
}
|
||||
|
||||
export async function sendMatrixMessage(
|
||||
token: string,
|
||||
roomKey: string,
|
||||
text: string,
|
||||
replyToEventId?: string | null
|
||||
): Promise<MatrixMessage> {
|
||||
return apiRequest<MatrixMessage>(matrixRoomPath(roomKey, '/messages'), {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { text, replyToEventId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function editMatrixMessage(token: string, roomKey: string, eventId: string, text: string): Promise<MatrixMessage> {
|
||||
return apiRequest<MatrixMessage>(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}`), {
|
||||
method: 'PUT',
|
||||
token,
|
||||
body: { text },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMatrixMessage(token: string, roomKey: string, eventId: string): Promise<void> {
|
||||
await apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}`), {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function reactToMatrixMessage(token: string, roomKey: string, eventId: string, key: string): Promise<void> {
|
||||
await apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}/reactions`), {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { key },
|
||||
});
|
||||
}
|
||||
|
||||
export async function markMatrixRoomRead(token: string, roomKey: string, eventId?: string): Promise<void> {
|
||||
await apiRequest(matrixRoomPath(roomKey, '/read'), {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { eventId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncMatrixMembers(token: string, roomKey: string): Promise<void> {
|
||||
await apiRequest(matrixRoomPath(roomKey, '/members/sync'), {
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function inviteMatrixMember(token: string, roomKey: string, userId: string): Promise<void> {
|
||||
await apiRequest(matrixRoomPath(roomKey, '/members/invite'), {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeMatrixMember(token: string, roomKey: string, matrixUserId: string): Promise<void> {
|
||||
await apiRequest(matrixRoomPath(roomKey, `/members/${encodeURIComponent(matrixUserId)}`), {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadMatrixAttachment(
|
||||
token: string,
|
||||
roomKey: string,
|
||||
file: { uri: string; name: string; mimeType?: string | null }
|
||||
): Promise<MatrixMessage> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', {
|
||||
uri: file.uri,
|
||||
name: file.name,
|
||||
type: file.mimeType || 'application/octet-stream',
|
||||
} as unknown as Blob);
|
||||
|
||||
return apiFormRequest<MatrixMessage>(matrixRoomPath(roomKey, '/attachments'), token, formData);
|
||||
}
|
||||
|
||||
export async function renderPrintLabel(
|
||||
token: string,
|
||||
context: Record<string, unknown>,
|
||||
@@ -508,7 +823,7 @@ export async function createCustomerInventoryItem(
|
||||
}
|
||||
|
||||
export async function fetchPlants(token: string, includeArchived = false): Promise<Plant[]> {
|
||||
const plants = await apiRequest<Array<Plant & { description?: unknown }>>('/api/resource/plants', { token });
|
||||
const plants = await apiRequest<(Plant & { description?: unknown })[]>('/api/resource/plants', { token });
|
||||
const normalized = (plants || []).map((plant) => {
|
||||
const legacyDescription = typeof plant.description === 'string'
|
||||
? plant.description
|
||||
|
||||
@@ -207,7 +207,7 @@ FEDEO Selfhost Setup
|
||||
Dieses Script führt dich durch die lokale Betriebsstruktur:
|
||||
|
||||
$ROOT_DIR/
|
||||
docker-compose.selfhost.yml Docker Stack für FEDEO, Traefik, PostgreSQL, MinIO und Matrix
|
||||
docker-compose.selfhost.yml Docker Stack für FEDEO, Traefik, PostgreSQL, MinIO, Matrix und Monitoring
|
||||
.env Zielkonfiguration, wird von diesem Script geschrieben
|
||||
postgres/ persistente FEDEO-Datenbank
|
||||
minio/ lokaler S3-kompatibler Dateispeicher
|
||||
@@ -320,6 +320,7 @@ DOKUBOX_IMAP_PASSWORD=$(env_quote "$dokubox_password")
|
||||
OPENAI_API_KEY=$(env_quote "$openai_key")
|
||||
STIRLING_API_KEY=$(env_quote "$stirling_key")
|
||||
NUXT_PUBLIC_PDF_LICENSE=$(env_quote "$pdf_license")
|
||||
NODE_EXPORTER_URL=$(env_quote "http://node-exporter:9100")
|
||||
|
||||
FEDEO_BOOTSTRAP_ADMIN_EMAIL=$(env_quote "$admin_email")
|
||||
FEDEO_BOOTSTRAP_ADMIN_PASSWORD=$(env_quote "$admin_password")
|
||||
@@ -484,7 +485,11 @@ main() {
|
||||
else
|
||||
echo
|
||||
echo "Start später mit:"
|
||||
echo " docker compose -f docker-compose.selfhost.yml up -d"
|
||||
if [[ "${FEDEO_USE_SUDO_DOCKER:-false}" == "true" ]]; then
|
||||
echo " sudo docker compose -f $COMPOSE_FILE up -d"
|
||||
else
|
||||
echo " docker compose -f $COMPOSE_FILE up -d"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -348,6 +348,7 @@ footer {
|
||||
|
||||
.workflow-section,
|
||||
.open-source-section,
|
||||
.script-section,
|
||||
.contact-section {
|
||||
align-items: start;
|
||||
display: grid;
|
||||
@@ -423,6 +424,86 @@ footer {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.script-section {
|
||||
border-top: 1px solid rgba(23, 33, 31, 0.12);
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 920px;
|
||||
padding-bottom: 5rem;
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
.script-section p {
|
||||
color: #51605c;
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.script-section > div:first-child {
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.code-window {
|
||||
background: #17211f;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1.5rem 4rem rgba(23, 33, 31, 0.22);
|
||||
color: #dff8d8;
|
||||
margin-top: 2rem;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.code-window-toolbar {
|
||||
align-items: center;
|
||||
background: #24302d;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
min-height: 3rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.code-window-toolbar span {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
height: 0.68rem;
|
||||
width: 0.68rem;
|
||||
}
|
||||
|
||||
.code-window-toolbar span:nth-child(2) {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.code-window-toolbar strong {
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
font-size: 0.86rem;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.code-window pre {
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
padding: clamp(1.25rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.code-window code {
|
||||
color: #dff8d8;
|
||||
display: block;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: clamp(0.92rem, 1.7vw, 1rem);
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.code-window code::before {
|
||||
color: var(--accent);
|
||||
content: "$ ";
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.contact-section {
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(23, 33, 31, 0.1);
|
||||
@@ -522,6 +603,7 @@ footer p {
|
||||
.hero-section,
|
||||
.workflow-section,
|
||||
.open-source-section,
|
||||
.script-section,
|
||||
.contact-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<nav aria-label="Hauptnavigation">
|
||||
<NuxtLink to="/#funktionen">Funktionen</NuxtLink>
|
||||
<NuxtLink to="/#open-source">Open Source</NuxtLink>
|
||||
<NuxtLink to="/#selfhost">Selfhost</NuxtLink>
|
||||
<NuxtLink to="/#kontakt">Kontakt</NuxtLink>
|
||||
<a class="login-link" href="https://app.fedeo.de">Einloggen</a>
|
||||
</nav>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<nav aria-label="Hauptnavigation">
|
||||
<a href="#funktionen">Funktionen</a>
|
||||
<a href="#open-source">Open Source</a>
|
||||
<a href="#selfhost">Selfhost</a>
|
||||
<a href="#kontakt">Kontakt</a>
|
||||
<a class="login-link" href="https://app.fedeo.de">Einloggen</a>
|
||||
</nav>
|
||||
@@ -130,6 +131,26 @@
|
||||
<a class="secondary-action" href="https://git.federspiel.tech/flfeders/FEDEO">Repository ansehen</a>
|
||||
</section>
|
||||
|
||||
<section id="selfhost" class="script-section">
|
||||
<div>
|
||||
<p class="eyebrow">Selfhosting</p>
|
||||
<h2>FEDEO auf deinem Server starten.</h2>
|
||||
<p>
|
||||
Der Installer prüft die Basisumgebung, richtet Docker bei Bedarf ein, lädt FEDEO und startet danach den geführten Selfhost-Assistenten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="code-window" aria-label="FEDEO Selfhost Installationsbefehl">
|
||||
<div class="code-window-toolbar">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<strong>Terminal</strong>
|
||||
</div>
|
||||
<pre><code>{{ installCommand }}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="kontakt" class="contact-section">
|
||||
<div>
|
||||
<p class="eyebrow">Jetzt anfragen</p>
|
||||
@@ -196,4 +217,6 @@ const features = [
|
||||
description: 'FEDEO ist für Arbeit am Schreibtisch und für mobile Abläufe im Einsatz vorbereitet.'
|
||||
}
|
||||
]
|
||||
|
||||
const installCommand = 'curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash -s -- --simple --start'
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user