diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..1ee7c96 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,36 @@ +# Product + +## Register + +product + +## Users + +Linux desktop users who need their existing LINE conversations, groups, OpenChat rooms, media, and account features without running a web wrapper, Wine, or Android container. They use the app throughout the day and expect chat switching, media, notifications, and keyboard workflows to respond immediately. Thai and English are first-class interface languages. + +## Product Purpose + +Provide a native GTK4 and Libadwaita LINE client for Linux, backed by linejs. Success means the everyday LINE workflow is familiar, locally cached, reliable, and fast while remaining honest about unofficial protocol limitations. + +## Brand Personality + +Familiar, direct, dependable. The interface should feel like LINE adapted carefully to a Linux desktop, with concise copy and native platform behavior. + +## Anti-references + +- Web wrappers that feel slow or ignore desktop conventions. +- Decorative chat interfaces that compete with conversation content. +- Controls that look unlike either LINE or the surrounding Libadwaita system. +- Silent failures, fake loading states, and features that appear available but do not work. + +## Design Principles + +- Keep conversation content primary and controls immediately understandable. +- Preserve familiar LINE concepts while using standard GTK affordances. +- Show cached content immediately, then reconcile network updates in the background. +- Make unavailable or experimental behavior explicit and recoverable. +- Keep per-chat preferences private, local, and reversible. + +## Accessibility & Inclusion + +Target WCAG AA contrast where theme colors are under app control. Follow the system light or dark theme, support reduced motion through the existing animation preference, retain visible focus and keyboard paths, avoid color-only state, and keep Thai and English layouts readable at the supported font scales. diff --git a/README.md b/README.md index 0d5e3b6..a239d11 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Unofficial native LINE client for Linux. GTK4 / Libadwaita UI with a Deno protoc | GTK4 + Libadwaita | `pacman -S gtk4 libadwaita` | | Deno | Build-time protocol compiler; source-tree runs use `deno` or `DENO` | | ffmpeg / ffprobe | voice record, video thumbs | +| wf-recorder + slurp | experimental Wayland screen sharing during calls | | Optional | `pdftoppm`, `pdftotext` for PDF preview | Wayland and X11 both work. Tested mainly on Arch-based desktops. diff --git a/assets/lang/eng.json b/assets/lang/eng.json index d99787c..19195f1 100644 --- a/assets/lang/eng.json +++ b/assets/lang/eng.json @@ -3,6 +3,7 @@ "app_name": "LINE GTK", "chats": "Chats", "send": "Send", + "cancel": "Cancel", "type_message": "Type a message…", "settings": "Settings", "features": "Features", @@ -198,6 +199,8 @@ "media_open_video": "Open video", "media_open_file": "Open file", "media_open_externally": "Open externally", + "media_previous_image": "Previous image (Left arrow)", + "media_next_image": "Next image (Right arrow)", "media_zoom_in": "Zoom in", "media_zoom_out": "Zoom out", "media_zoom_reset": "Reset zoom", @@ -212,6 +215,20 @@ "media_unsupported_preview": "No in-app preview for this file. Download or open externally.", "pick_voice": "Choose audio file", "attach_file": "Attach photo, video, audio, or file", + "media_review_title": "Review attachments", + "media_review_hint": "Check, add, or remove items before sending", + "media_review_add": "Add files", + "media_review_remove": "Remove from this send", + "media_review_empty": "Add at least one photo or file to send.", + "media_review_count": "{n} items selected", + "media_review_send": "Send {n}", + "message_react": "React", + "message_reaction_failed": "Could not add reaction: {error}", + "message_unsend": "Unsend message", + "message_unsend_title": "Unsend this message?", + "message_unsend_body": "This removes the message for everyone in the chat. This cannot be undone.", + "message_unsent": "Message unsent", + "message_unsend_failed": "Could not unsend message: {error}", "stickers": "Stickers", "stickers_recent": "Recent", "loading_stickers": "Loading stickers…", @@ -281,6 +298,25 @@ "call_spk_vol": "Speaker volume", "call_mic_vol_subtitle": "Outgoing call gain (0-2)", "call_spk_vol_subtitle": "Incoming call gain (0-2)", + "call_connected_short": "Connected", + "call_share_screen": "Share screen", + "call_stop_sharing": "Stop sharing", + "call_share_unavailable": "Screen sharing was not negotiated for this call", + "call_share_selecting": "Select a window or screen to share…", + "call_share_starting": "Starting screen share…", + "call_share_active": "Sharing your screen", + "call_share_failed": "Screen share failed: {error}", + "profile_checking": "Checking friendship…", + "profile_friend": "LINE friend", + "profile_not_friend": "Add this person before starting a private chat", + "profile_add_friend": "Add friend", + "profile_private_chat": "Private chat", + "profile_add_success": "Friend added", + "profile_add_failed": "Could not add friend: {error}", + "profile_check_failed": "Could not check friendship: {error}", + "profile_blocked": "This profile is blocked and cannot be added here", + "profile_self": "This is your profile", + "profile_openchat_private_unavailable": "OpenChat profiles cannot be added or opened as private LINE chats", "notification_sound": "Notification sound", "notification_sound_subtitle": "Play the system notification sound with alerts", "notification_sound_vol": "Notification volume", @@ -293,6 +329,21 @@ "audio_output_subtitle": "PulseAudio / PipeWire sink", "mute_chat": "Mute notifications", "unmute_chat": "Unmute notifications", + "pin_chat": "Pin chat", + "unpin_chat": "Unpin chat", + "chat_album": "Album / chat media", + "chat_album_empty": "No downloaded photos or videos in the loaded messages yet", + "chat_background": "Chat background", + "chat_background_default": "Default", + "chat_background_mint": "Mint", + "chat_background_ocean": "Ocean", + "chat_background_dusk": "Dusk", + "chat_background_graphite": "Graphite", + "chat_background_choose_image": "Choose image", + "chat_background_images": "Background images", + "chat_background_saved": "Chat background saved", + "chat_background_failed": "Could not display this background", + "chat_background_save_failed": "Could not save background: {error}", "chat_muted": "Chat muted (LINE server)", "chat_unmuted": "Chat unmuted", "mute_dm_only": "Mute is only for 1:1 friends", diff --git a/assets/lang/thai.json b/assets/lang/thai.json index a7c2020..505653d 100644 --- a/assets/lang/thai.json +++ b/assets/lang/thai.json @@ -3,6 +3,7 @@ "app_name": "LINE GTK", "chats": "แชท", "send": "ส่ง", + "cancel": "ยกเลิก", "type_message": "พิมพ์ข้อความ…", "settings": "ตั้งค่า", "features": "ฟีเจอร์", @@ -198,6 +199,8 @@ "media_open_video": "เปิดวิดีโอ", "media_open_file": "เปิดไฟล์", "media_open_externally": "เปิดด้วยแอปอื่น", + "media_previous_image": "รูปก่อนหน้า (ลูกศรซ้าย)", + "media_next_image": "รูปถัดไป (ลูกศรขวา)", "media_zoom_in": "ซูมเข้า", "media_zoom_out": "ซูมออก", "media_zoom_reset": "รีเซ็ตซูม", @@ -212,6 +215,20 @@ "media_unsupported_preview": "ไม่มีพรีวิวในแอป ดาวน์โหลดหรือเปิดด้วยแอปอื่น", "pick_voice": "เลือกไฟล์เสียง", "attach_file": "แนบรูป วิดีโอ เสียง หรือไฟล์", + "media_review_title": "ตรวจสอบก่อนส่ง", + "media_review_hint": "ดู เพิ่ม หรือนำรายการออกก่อนกดส่ง", + "media_review_add": "เพิ่มไฟล์", + "media_review_remove": "นำออกจากชุดที่จะส่ง", + "media_review_empty": "เพิ่มรูปหรือไฟล์อย่างน้อยหนึ่งรายการ", + "media_review_count": "เลือกแล้ว {n} รายการ", + "media_review_send": "ส่ง {n} รายการ", + "message_react": "รีแอคชั่น", + "message_reaction_failed": "ใส่รีแอคชั่นไม่สำเร็จ: {error}", + "message_unsend": "ยกเลิกข้อความ", + "message_unsend_title": "ยกเลิกข้อความนี้ไหม", + "message_unsend_body": "ข้อความจะหายจากแชทของทุกคน และย้อนกลับไม่ได้", + "message_unsent": "ยกเลิกข้อความแล้ว", + "message_unsend_failed": "ยกเลิกข้อความไม่สำเร็จ: {error}", "stickers": "สติกเกอร์", "stickers_recent": "ล่าสุด", "loading_stickers": "กำลังโหลดสติกเกอร์…", @@ -281,6 +298,25 @@ "call_spk_vol": "ระดับลำโพง", "call_mic_vol_subtitle": "ความดังไมค์ตอนโทร (0-2)", "call_spk_vol_subtitle": "ความดังเสียงที่รับ (0-2)", + "call_connected_short": "เชื่อมต่อแล้ว", + "call_share_screen": "แชร์หน้าจอ", + "call_stop_sharing": "หยุดแชร์หน้าจอ", + "call_share_unavailable": "สายนี้ยังไม่ได้เจรจาช่องวิดีโอสำหรับแชร์หน้าจอ", + "call_share_selecting": "เลือกหน้าต่างหรือจอที่ต้องการแชร์…", + "call_share_starting": "กำลังเริ่มแชร์หน้าจอ…", + "call_share_active": "กำลังแชร์หน้าจอของคุณ", + "call_share_failed": "แชร์หน้าจอไม่สำเร็จ: {error}", + "profile_checking": "กำลังตรวจสอบสถานะเพื่อน…", + "profile_friend": "เป็นเพื่อนใน LINE แล้ว", + "profile_not_friend": "เพิ่มคนนี้เป็นเพื่อนก่อนเริ่มแชทส่วนตัว", + "profile_add_friend": "เพิ่มเพื่อน", + "profile_private_chat": "แชทส่วนตัว", + "profile_add_success": "เพิ่มเพื่อนแล้ว", + "profile_add_failed": "เพิ่มเพื่อนไม่สำเร็จ: {error}", + "profile_check_failed": "ตรวจสอบสถานะเพื่อนไม่สำเร็จ: {error}", + "profile_blocked": "โปรไฟล์นี้ถูกบล็อก จึงเพิ่มเพื่อนจากหน้านี้ไม่ได้", + "profile_self": "นี่คือโปรไฟล์ของคุณ", + "profile_openchat_private_unavailable": "โปรไฟล์ OpenChat ไม่สามารถเพิ่มเพื่อนหรือเปิดเป็นแชท LINE ส่วนตัวได้", "notification_sound": "เสียงแจ้งเตือน", "notification_sound_subtitle": "เล่นเสียงแจ้งเตือนของระบบเมื่อมีข้อความ", "notification_sound_vol": "ระดับเสียงแจ้งเตือน", @@ -293,6 +329,21 @@ "audio_output_subtitle": "sink ของ PulseAudio / PipeWire", "mute_chat": "ปิดแจ้งเตือนแชท", "unmute_chat": "เปิดแจ้งเตือนแชท", + "pin_chat": "ปักหมุดแชท", + "unpin_chat": "เลิกปักหมุดแชท", + "chat_album": "อัลบั้ม / สื่อในแชท", + "chat_album_empty": "ยังไม่มีรูปหรือวิดีโอที่โหลดแล้วในข้อความชุดนี้", + "chat_background": "พื้นหลังแชท", + "chat_background_default": "ค่าเริ่มต้น", + "chat_background_mint": "มินต์", + "chat_background_ocean": "มหาสมุทร", + "chat_background_dusk": "ยามเย็น", + "chat_background_graphite": "กราไฟต์", + "chat_background_choose_image": "เลือกรูปภาพ", + "chat_background_images": "รูปพื้นหลัง", + "chat_background_saved": "บันทึกพื้นหลังแชทแล้ว", + "chat_background_failed": "แสดงพื้นหลังนี้ไม่ได้", + "chat_background_save_failed": "บันทึกพื้นหลังไม่ได้: {error}", "chat_muted": "ปิดแจ้งเตือนแล้ว (ฝั่งเซิร์ฟเวอร์ LINE)", "chat_unmuted": "เปิดแจ้งเตือนแล้ว", "mute_dm_only": "ปิดเสียงได้เฉพาะเพื่อน 1:1", diff --git a/protocol/deno.json b/protocol/deno.json index 3c6b1d0..6e28ce0 100644 --- a/protocol/deno.json +++ b/protocol/deno.json @@ -1,7 +1,7 @@ { "tasks": { - "sidecar": "deno run --no-prompt --allow-read --allow-write --allow-net --allow-run=ffmpeg,ffprobe --allow-sys --allow-env src/main.ts", - "dev": "deno run --no-prompt --watch --allow-read --allow-write --allow-net --allow-run=ffmpeg,ffprobe --allow-sys --allow-env src/main.ts", + "sidecar": "deno run --no-prompt --allow-read --allow-write --allow-net --allow-run=ffmpeg,ffprobe,slurp,wf-recorder --allow-sys --allow-env src/main.ts", + "dev": "deno run --no-prompt --watch --allow-read --allow-write --allow-net --allow-run=ffmpeg,ffprobe,slurp,wf-recorder --allow-sys --allow-env src/main.ts", "check": "deno fmt --check && deno lint && deno check src/main.ts && deno test --allow-read --allow-write tests/" }, "lint": { diff --git a/protocol/src/calls.ts b/protocol/src/calls.ts index 4bc2412..e96a87a 100644 --- a/protocol/src/calls.ts +++ b/protocol/src/calls.ts @@ -1,5 +1,10 @@ import type { Client } from "@evex/linejs"; import type { LineDevice } from "./auth.ts"; +import { + AnnexBParser, + H264AccessUnitAssembler, + packetizeH264AccessUnit, +} from "./h264.ts"; type Json = Record; type CallRuntime = { @@ -62,9 +67,17 @@ type ActiveCall = { payload: Uint8Array, opts?: { timestampStep?: number }, ) => Promise; + videoReady?: () => boolean; + sendVideo?: ( + payload: Uint8Array, + opts?: { endOfFrame?: boolean; timestampStep?: number }, + ) => Promise; receive: () => AsyncIterable; }; stopAudio?: () => void; + stopScreenShare?: () => void; + videoCapable: boolean; + screenSharing: boolean; aborted: boolean; }; @@ -187,7 +200,11 @@ function logTransportDebug( console.error("[call debug]", extras ? { ...ev, ...extras } : ev); } -export async function doCallStart(id: number | string | null, peerMid: string) { +export async function doCallStart( + id: number | string | null, + peerMid: string, + preferVideo = false, +) { const client = runtime.getClient(); callLog("start requested", { peerMid, @@ -242,6 +259,8 @@ export async function doCallStart(id: number | string | null, peerMid: string) { peer: peerMid, direction: "out", localMid, + videoCapable: preferVideo, + screenSharing: false, aborted: false, }; const call = activeCall; @@ -259,7 +278,24 @@ export async function doCallStart(id: number | string | null, peerMid: string) { fromEnvInfo: LINE_CALL_DEVNAME || "(none)", deviceInfo: planetDeviceInfo(), }); - const route = await client.call.acquireRoute({ + let route: Awaited> | undefined; + if (preferVideo) { + try { + route = await client.call.acquireRoute({ + to: peerMid, + callType: "VIDEO", + ...(LINE_CALL_DEVNAME + ? { fromEnvInfo: { devname: LINE_CALL_DEVNAME } } + : {}), + }); + } catch (error) { + call.videoCapable = false; + callLog("video route unavailable; falling back to audio", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + route ??= await client.call.acquireRoute({ to: peerMid, callType: "AUDIO", ...(LINE_CALL_DEVNAME @@ -286,6 +322,9 @@ export async function doCallStart(id: number | string | null, peerMid: string) { localMid, timeoutMs: 10_000, mediaKeyMode: "audio-reverse-stage", + enableVideo: call.videoCapable, + videoBitrateKbps: 800, + videoFps: 15, deviceInfo: planetDeviceInfo(), debug: (ev: Record) => { if (ev.type === "rel_req") { @@ -436,7 +475,13 @@ export async function doCallStart(id: number | string | null, peerMid: string) { ); } - emitEvent("call_state", { callId, peer: peerMid, state: "connected" }); + call.videoCapable = call.videoCapable && !!transport.videoReady?.(); + emitEvent("call_state", { + callId, + peer: peerMid, + state: "connected", + videoCapable: call.videoCapable, + }); callLog("starting audio I/O"); call.stopAudio = await startCallAudioIO( transport, @@ -512,7 +557,7 @@ export async function doCallAnswer(id: number | string | null) { // Answering starts a fresh outgoing audio session to the caller so media // can negotiate; hangup still sends REL_REQ to stop their ring. ok(id, { answering: true, peer: offer.from, callId: offer.callId }); - await doCallStart(null, offer.from); + await doCallStart(null, offer.from, false); } export async function doCallDecline(id: number | string | null) { @@ -855,6 +900,190 @@ async function startCallAudioIO( }; } +type ScreenShareControl = { + stopped: boolean; + selector?: Deno.ChildProcess; + recorder?: Deno.ChildProcess; +}; + +const SCREEN_SHARE_FPS = 15; +const SCREEN_SHARE_RTP_STEP = Math.round(90_000 / SCREEN_SHARE_FPS); + +export function doCallScreenStart(id: number | string | null) { + const call = activeCall; + const transport = call?.transport; + if (!call || !transport) { + fail(id, "call_not_connected"); + return; + } + if (!call.videoCapable || !transport.videoReady?.() || !transport.sendVideo) { + fail(id, "call_video_not_negotiated"); + return; + } + if (call.screenSharing) { + ok(id, { state: "active" }); + return; + } + + const control: ScreenShareControl = { stopped: false }; + call.screenSharing = true; + call.stopScreenShare = () => { + control.stopped = true; + for (const child of [control.selector, control.recorder]) { + try { + child?.kill("SIGINT"); + } catch { /* already exited */ } + } + }; + ok(id, { state: "selecting" }); + emitEvent("screen_share_state", { state: "selecting" }); + + void runScreenShare(call, control).catch((error) => { + if (!control.stopped) { + const message = error instanceof Error ? error.message : String(error); + callLog("screen share failed", { error: message }); + emitEvent("screen_share_state", { state: "failed", error: message }); + } + }).finally(() => { + try { + control.recorder?.kill("SIGINT"); + } catch { /* already exited */ } + if (activeCall === call) { + call.screenSharing = false; + call.stopScreenShare = undefined; + if (control.stopped) { + emitEvent("screen_share_state", { state: "stopped" }); + } + } + }); +} + +export function doCallScreenStop(id: number | string | null) { + const call = activeCall; + if (!call?.screenSharing) { + ok(id, { state: "stopped" }); + return; + } + call.stopScreenShare?.(); + ok(id, { state: "stopping" }); +} + +async function runScreenShare(call: ActiveCall, control: ScreenShareControl) { + const selector = new Deno.Command("slurp", { + args: ["-f", "%x,%y %wx%h"], + stdin: "null", + stdout: "piped", + stderr: "piped", + }).spawn(); + control.selector = selector; + const selected = await selector.output(); + control.selector = undefined; + if (control.stopped) return; + const geometry = new TextDecoder().decode(selected.stdout).trim(); + if (!selected.success || !/^\d+,\d+ \d+x\d+$/.test(geometry)) { + throw new Error("screen_share_selection_canceled"); + } + + emitEvent("screen_share_state", { state: "starting", geometry }); + const recorder = new Deno.Command("wf-recorder", { + args: [ + "-g", + geometry, + "-r", + String(SCREEN_SHARE_FPS), + "-x", + "yuv420p", + "-c", + "libx264", + "-m", + "h264", + "-p", + "preset=ultrafast", + "-p", + "tune=zerolatency", + "-p", + "profile=baseline", + "-p", + "crf=28", + "-p", + "g=30", + "-p", + "bf=0", + "-p", + "x264-params=aud=1:repeat-headers=1:slices=1:scenecut=0", + "-f", + "-", + ], + stdin: "null", + stdout: "piped", + stderr: "piped", + }).spawn(); + control.recorder = recorder; + const stderr = readProcessStderr(recorder.stderr); + emitEvent("screen_share_state", { state: "active", geometry }); + + const annexB = new AnnexBParser(); + const accessUnits = new H264AccessUnitAssembler(); + const reader = recorder.stdout.getReader(); + try { + while (!control.stopped && activeCall === call && !call.aborted) { + const { value, done } = await reader.read(); + if (done) break; + if (!value?.length) continue; + for (const nal of annexB.push(value)) { + for (const unit of accessUnits.push(nal)) { + await sendScreenAccessUnit(call, unit); + } + } + } + for (const nal of annexB.flush()) { + for (const unit of accessUnits.push(nal)) { + await sendScreenAccessUnit(call, unit); + } + } + for (const unit of accessUnits.flush()) { + await sendScreenAccessUnit(call, unit); + } + } finally { + try { + await reader.cancel(); + } catch { /* ignore */ } + } + + const status = await recorder.status; + const stderrTail = await stderr; + if (!control.stopped && !status.success) { + throw new Error(stderrTail || `wf-recorder exited ${status.code}`); + } +} + +async function sendScreenAccessUnit(call: ActiveCall, nals: Uint8Array[]) { + const sendVideo = call.transport?.sendVideo; + if (!sendVideo || !nals.length) return; + for (const packet of packetizeH264AccessUnit(nals)) { + await sendVideo(packet.payload, { + endOfFrame: packet.endOfFrame, + timestampStep: SCREEN_SHARE_RTP_STEP, + }); + } +} + +async function readProcessStderr( + stream: ReadableStream, +): Promise { + const reader = stream.getReader(); + let tail = ""; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (!value) continue; + tail = (tail + new TextDecoder().decode(value)).slice(-4096); + } + } catch { /* process closed */ } + return tail.trim(); +} + async function endActiveCall(opts: { silent?: boolean } = {}) { const cur = activeCall; if (!cur || cur.aborted) { @@ -878,6 +1107,9 @@ async function endActiveCall(opts: { silent?: boolean } = {}) { try { cur.stopAudio?.(); } catch { /* ignore */ } + try { + cur.stopScreenShare?.(); + } catch { /* ignore */ } if (cur.transport) { try { diff --git a/protocol/src/commands.ts b/protocol/src/commands.ts index f3d015c..ab580dd 100644 --- a/protocol/src/commands.ts +++ b/protocol/src/commands.ts @@ -2,13 +2,14 @@ import { type Client, loginWithAuthToken, loginWithQR, + SquareMessage, type TalkMessage, } from "@evex/linejs"; import { FileStorage } from "@evex/linejs/storage"; import { join } from "@std/path"; import { atomicWriteJson } from "./storage.ts"; import type { CachePolicy } from "./cache_policy.ts"; -import { friendName, picturePathOf } from "./contacts.ts"; +import { friendName, picturePathOf, squareObsUrl } from "./contacts.ts"; import type { LineDevice } from "./auth.ts"; type Json = Record; @@ -22,15 +23,25 @@ type ChatRow = { unread: number; preview: string; muted?: boolean; + pinned?: boolean; }; type ChatCache = { at: number; chats: ChatRow[] }; type BoxCursor = { messageId: bigint | number; deliveredTime: bigint | number }; type ContactInfo = { name: string; picturePath: string | null; + statusMessage: string; kind: string; muted: boolean; }; +type MessageHistoryCursor = { messageId: string; deliveredTime: string }; +type MessageCacheEntry = { + at: number; + messages: Json[]; + historyComplete?: boolean; + oldestCursor?: MessageHistoryCursor; + squareSyncToken?: string; +}; export type CommandRuntime = { getClient: () => Client | null; @@ -39,8 +50,9 @@ export type CommandRuntime = { setAuthDead: (dead: boolean) => void; getChatCache: () => ChatCache | null; setChatCache: (cache: ChatCache | null) => void; - msgCache: Map; + msgCache: Map; contactIndex: Map; + squareChatIndex: Set; boxCursor: Map; avatarDir: string; chatCachePath: string; @@ -60,14 +72,21 @@ export type CommandRuntime = { hydrateAvatars: (chats: ChatRow[]) => Promise; hydratePreviews: (chats: ChatRow[]) => Promise; bumpMediaEpoch: (chatMid: string) => number; - loadDiskMessages: (chatMid: string) => Promise; - saveDiskMessages: (chatMid: string, messages: Json[]) => Promise; + loadDiskMessages: (chatMid: string) => Promise; + saveDiskMessages: ( + chatMid: string, + entry: MessageCacheEntry, + ) => Promise; forUiMessages: (messages: Json[]) => Promise; hydrateMedia: (messages: Json[], chatMid: string) => Promise; summarizeTalkMessage: ( message: TalkMessage, opts?: { withMedia?: boolean }, ) => Promise; + summarizeSquareMessage: ( + message: SquareMessage, + opts?: { withMedia?: boolean }, + ) => Promise; summarizeRawMessage: ( raw: unknown, opts?: { withMedia?: boolean }, @@ -101,6 +120,7 @@ export function createCommandService(runtime: CommandRuntime) { const { msgCache, contactIndex, + squareChatIndex, boxCursor, avatarDir, chatCachePath, @@ -122,6 +142,7 @@ export function createCommandService(runtime: CommandRuntime) { forUiMessages, hydrateMedia, summarizeTalkMessage, + summarizeSquareMessage, summarizeRawMessage, saveAuth, loadAuth, @@ -154,6 +175,20 @@ export function createCommandService(runtime: CommandRuntime) { return; } try { + if (squareChatIndex.has(chatMid) || chatMid.startsWith("m")) { + await client.base.square.markAsRead({ + request: { + squareChatMid: chatMid, + messageId: String(lastMessageId), + }, + }); + ok(id, { + marked: true, + chatMid, + lastMessageId: String(lastMessageId), + }); + return; + } const requestSequence = client.base.getReqseq; const seq = typeof requestSequence === "function" ? await requestSequence.call(client.base) @@ -322,6 +357,8 @@ export function createCommandService(runtime: CommandRuntime) { unread: Number(box.unreadCount ?? 0), preview: prevPreview, muted: !!contactIndex.get(mid)?.muted, + pinned: chatCache?.chats.find((x) => x.mid === mid)?.pinned ?? + false, }); } } catch (e) { @@ -355,6 +392,7 @@ export function createCommandService(runtime: CommandRuntime) { unread: 0, preview: "", muted: !!info.muted, + pinned: false, }); } } @@ -371,6 +409,7 @@ export function createCommandService(runtime: CommandRuntime) { contactIndex.set(user.mid, { name, picturePath, + statusMessage: "", kind: "dm", muted: false, }); @@ -385,6 +424,33 @@ export function createCommandService(runtime: CommandRuntime) { } } + // Keep Memo is LINE's notes-to-self conversation. It uses the signed-in + // account MID as a regular Talk message box, so expose it even before the + // account has sent its first memo and a recent box exists. + try { + const profile = client!.base.profile; + if (profile?.mid) { + const prev = byMid.get(profile.mid); + byMid.set(profile.mid, { + mid: profile.mid, + name: "Keep Memo", + kind: "keep", + picturePath: picturePathOf( + profile as unknown as Record, + ), + avatarPath: prev?.avatarPath ?? + await existingFile(join(avatarDir, `${profile.mid}.jpg`)), + lastActivity: prev?.lastActivity ?? 0, + unread: prev?.unread ?? 0, + preview: prev?.preview ?? "", + muted: false, + pinned: prev?.pinned ?? false, + }); + } + } catch (e) { + console.error("[list_chats] keep memo", e); + } + // 3) Groups optional (often empty) — don't block naming try { const chats = await client!.fetchJoinedChats(); @@ -397,15 +463,47 @@ export function createCommandService(runtime: CommandRuntime) { lastActivity: prev?.lastActivity ?? 0, unread: prev?.unread ?? 0, preview: prev?.preview ?? "", - picturePath: prev?.picturePath ?? null, + // Group chat pictures live directly on Chat.picturePath; using the + // contact index here produced blank/default avatars. + picturePath: chat.raw.picturePath || prev?.picturePath || null, avatarPath: prev?.avatarPath ?? null, + muted: chat.raw.notificationDisabled ?? prev?.muted ?? false, + pinned: Number(chat.raw.favoriteTimestamp ?? 0) > 0, }); } } catch (e) { console.error("[list_chats] groups", e); } + // 4) OpenChat (Square) rooms joined by this account. + try { + const openChats = await client!.fetchJoinedSquareChats(); + for (const chat of openChats) { + const mid = chat.mid; + if (!mid) continue; + squareChatIndex.add(mid); + const prev = byMid.get(mid); + const picturePath = squareObsUrl(chat.raw.chatImageObsHash); + byMid.set(mid, { + mid, + name: chat.name || mid, + kind: "openchat", + lastActivity: prev?.lastActivity ?? 0, + unread: prev?.unread ?? 0, + preview: prev?.preview ?? "", + picturePath, + avatarPath: await existingFile(join(avatarDir, `${mid}.jpg`)), + muted: false, + pinned: prev?.pinned ?? false, + }); + } + } catch (e) { + // Accounts/device types without Square access should still load Talk. + console.error("[list_chats] openchat", e); + } + const chats = [...byMid.values()].sort((a, b) => { + if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1; if (b.lastActivity !== a.lastActivity) { return b.lastActivity - a.lastActivity; } @@ -432,10 +530,346 @@ export function createCommandService(runtime: CommandRuntime) { }); } + function mergeMessageRows(current: Json[], incoming: Json[]): Json[] { + const byId = new Map(); + for (const [index, message] of current.entries()) { + const id = String( + message.id ?? `cached:${index}:${message.createdTime ?? 0}`, + ); + byId.set(id, message); + } + for (const [index, message] of incoming.entries()) { + const id = String( + message.id ?? `incoming:${index}:${message.createdTime ?? 0}`, + ); + const previous = byId.get(id); + byId.set( + id, + previous + ? { + ...previous, + ...message, + imagePath: message.imagePath || previous.imagePath || null, + audioPath: message.audioPath || previous.audioPath || null, + filePath: message.filePath || previous.filePath || null, + } + : message, + ); + } + return [...byId.values()].sort((a, b) => + Number(a.createdTime ?? 0) - Number(b.createdTime ?? 0) + ); + } + + async function cacheMessageRow(chatMid: string, message: Json) { + let entry = msgCache.get(chatMid); + if (!entry) entry = await loadDiskMessages(chatMid) ?? undefined; + entry ??= { at: Date.now(), messages: [], historyComplete: false }; + entry.messages = mergeMessageRows(entry.messages, [message]); + entry.at = Date.now(); + msgCache.set(chatMid, entry); + void saveDiskMessages(chatMid, entry); + } + + function rawHistoryCursor(raw: unknown): MessageHistoryCursor | undefined { + const row = raw as { id?: unknown; deliveredTime?: unknown }; + if (row?.id == null) return undefined; + return { + messageId: String(row.id), + deliveredTime: String(row.deliveredTime ?? 0), + }; + } + + async function refreshBoxCursor( + chatMid: string, + ): Promise { + const client = runtime.getClient(); + if (!client) return undefined; + let cursor = boxCursor.get(chatMid); + if (cursor) return cursor; + const boxes = await client.base.talk.getMessageBoxes({ + messageBoxListRequest: {}, + }); + for (const box of boxes.messageBoxes ?? []) { + storeBoxCursor(String(box.id ?? ""), box.lastDeliveredMessageId); + } + cursor = boxCursor.get(chatMid); + return cursor; + } + + async function summarizeTalkHistoryPage( + rawMessages: unknown[], + ): Promise { + const out: Json[] = []; + for (const raw of rawMessages) { + try { + const meta = (raw as { contentMetadata?: Record }) + .contentMetadata; + if (meta?.BOT_CHECK || meta?.BOT_ORIGIN) { + const from = String((raw as { from?: unknown }).from ?? ""); + const contact = contactIndex.get(from); + if (contact) contact.kind = "bot"; + } + out.push(await summarizeRawMessage(raw, { withMedia: false })); + } catch (error) { + console.error("[fetch msg skip]", error); + } + } + return out; + } + + async function summarizeSquareHistoryEvents( + events: unknown[], + ): Promise { + const client = runtime.getClient(); + if (!client) return []; + const out: Json[] = []; + for (const event of events) { + const payload = (event as { payload?: unknown }).payload as { + sendMessage?: { squareMessage?: unknown }; + receiveMessage?: { squareMessage?: unknown }; + notificationMessage?: { squareMessage?: unknown }; + } | undefined; + const raw = payload?.sendMessage?.squareMessage ?? + payload?.receiveMessage?.squareMessage ?? + payload?.notificationMessage?.squareMessage; + if (!raw) continue; + try { + const message = SquareMessage.fromRawTalk( + raw as Parameters[0], + client, + ); + out.push(await summarizeSquareMessage(message, { withMedia: false })); + } catch (error) { + console.error("[fetch openchat msg skip]", error); + } + } + return out; + } + + async function publishMessageHistory( + chatMid: string, + entry: MessageCacheEntry, + state: "syncing" | "complete" = "syncing", + ) { + entry.at = Date.now(); + msgCache.set(chatMid, entry); + await saveDiskMessages(chatMid, entry); + emitEvent("messages", { + chatMid, + messages: await forUiMessages(entry.messages), + cached: false, + historyComplete: !!entry.historyComplete, + }); + emitEvent("progress", { + scope: "messages", + chatMid, + state, + count: entry.messages.length, + }); + } + + async function seedMessageHistory( + chatMid: string, + ): Promise { + const client = runtime.getClient(); + if (!client) throw new Error("not_logged_in"); + if (squareChatIndex.has(chatMid) || chatMid.startsWith("m")) { + squareChatIndex.add(chatMid); + const response = await client.base.square.fetchSquareChatEvents({ + squareChatMid: chatMid, + limit: 100, + direction: "BACKWARD", + }); + return { + at: Date.now(), + messages: await summarizeSquareHistoryEvents(response.events ?? []), + historyComplete: !(response.events?.length), + squareSyncToken: response.syncToken || undefined, + }; + } + + const cursor = await refreshBoxCursor(chatMid); + if (!cursor) { + return { at: Date.now(), messages: [], historyComplete: true }; + } + const raw = await client.base.talk.getPreviousMessagesV2WithRequest({ + request: { + messageBoxId: chatMid, + endMessageId: cursor, + messagesCount: 100, + }, + }); + return { + at: Date.now(), + messages: await summarizeTalkHistoryPage(raw), + historyComplete: raw.length === 0, + oldestCursor: rawHistoryCursor(raw.at(-1)), + }; + } + + async function syncTalkHistory(chatMid: string, initial: MessageCacheEntry) { + const client = runtime.getClient(); + if (!client) return; + let entry = msgCache.get(chatMid) ?? initial; + const newest = await refreshBoxCursor(chatMid); + + // First close the gap from the newest server message back to any local row. + if (newest && !entry.messages.length) { + const raw = await client.base.talk.getPreviousMessagesV2WithRequest({ + request: { + messageBoxId: chatMid, + endMessageId: newest, + messagesCount: 100, + }, + }); + entry.messages = mergeMessageRows( + entry.messages, + await summarizeTalkHistoryPage(raw), + ); + entry.oldestCursor = rawHistoryCursor(raw.at(-1)); + entry.historyComplete = raw.length < 100; + msgCache.set(chatMid, entry); + } else if (newest && entry.messages.length) { + const known = new Set( + entry.messages.map((message) => String(message.id ?? "")), + ); + let end: BoxCursor = newest; + for (let page = 0; page < 10_000; page++) { + const raw = await client.base.talk.getPreviousMessagesV2WithRequest({ + request: { + messageBoxId: chatMid, + endMessageId: end, + messagesCount: 100, + }, + }); + if (!raw.length) break; + const overlap = raw.some((message) => + known.has(String((message as { id?: unknown }).id ?? "")) + ); + const rows = await summarizeTalkHistoryPage(raw); + entry = msgCache.get(chatMid) ?? entry; + entry.messages = mergeMessageRows(entry.messages, rows); + entry.at = Date.now(); + msgCache.set(chatMid, entry); + const oldest = rawHistoryCursor(raw.at(-1)); + if (!oldest || overlap) break; + end = { + messageId: BigInt(oldest.messageId), + deliveredTime: BigInt(oldest.deliveredTime), + }; + if (raw.length < 100) { + entry.historyComplete = true; + entry.oldestCursor = oldest; + break; + } + } + } + + // Continue the durable oldest cursor until LINE says there is no older page. + if (!entry.historyComplete) { + let oldest = entry.oldestCursor; + if (!oldest && entry.messages.length) { + const first = entry.messages[0]!; + oldest = { + messageId: String(first.id ?? ""), + deliveredTime: String(first.createdTime ?? 0), + }; + } + let pagesSincePublish = 0; + while (oldest?.messageId) { + const raw = await client.base.talk.getPreviousMessagesV2WithRequest({ + request: { + messageBoxId: chatMid, + endMessageId: { + messageId: BigInt(oldest.messageId), + deliveredTime: BigInt(oldest.deliveredTime), + }, + messagesCount: 100, + }, + }); + if (!raw.length) { + entry.historyComplete = true; + break; + } + const nextOldest = rawHistoryCursor(raw.at(-1)); + if (!nextOldest) break; + const before = entry.messages.length; + const rows = await summarizeTalkHistoryPage(raw); + entry = msgCache.get(chatMid) ?? entry; + entry.messages = mergeMessageRows(entry.messages, rows); + entry.oldestCursor = nextOldest; + pagesSincePublish++; + const stuck = nextOldest.messageId === oldest.messageId; + oldest = nextOldest; + if (raw.length < 100 || stuck || entry.messages.length === before) { + entry.historyComplete = raw.length < 100 || stuck; + break; + } + if (pagesSincePublish >= 5) { + pagesSincePublish = 0; + await publishMessageHistory(chatMid, entry); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + await publishMessageHistory(chatMid, entry, "complete"); + void hydrateMedia( + await forUiMessages(entry.messages.slice(-200)), + chatMid, + ); + } + + async function syncSquareHistory( + chatMid: string, + initial: MessageCacheEntry, + ) { + const client = runtime.getClient(); + if (!client) return; + let entry = msgCache.get(chatMid) ?? initial; + let token = entry.squareSyncToken; + let pagesSincePublish = 0; + while (!entry.historyComplete) { + const response = await client.base.square.fetchSquareChatEvents({ + squareChatMid: chatMid, + syncToken: token, + limit: 100, + direction: "BACKWARD", + }); + const events = response.events ?? []; + const nextToken = response.syncToken || undefined; + if (!events.length || (token && nextToken === token)) { + entry.historyComplete = true; + break; + } + const rows = await summarizeSquareHistoryEvents(events); + entry = msgCache.get(chatMid) ?? entry; + const before = entry.messages.length; + entry.messages = mergeMessageRows(entry.messages, rows); + entry.squareSyncToken = nextToken; + token = nextToken; + pagesSincePublish++; + if (entry.messages.length === before) { + entry.historyComplete = true; + break; + } + if (pagesSincePublish >= 5) { + pagesSincePublish = 0; + await publishMessageHistory(chatMid, entry); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + await publishMessageHistory(chatMid, entry, "complete"); + void hydrateMedia( + await forUiMessages(entry.messages.slice(-200)), + chatMid, + ); + } + async function doFetchMessages( id: number | string | null, chatMid: string, - limit = 50, + _limit = 50, force = false, ) { const client = runtime.getClient(); @@ -448,150 +882,59 @@ export function createCommandService(runtime: CommandRuntime) { return; } - await refreshCachePolicy(); - await dedupe(`msgs:${chatMid}:${limit}:${force}`, async () => { - bumpMediaEpoch(chatMid); // cancel any prior hydrate for this chat - const cached = msgCache.get(chatMid); - if (!force && cached && Date.now() - cached.at < cachePolicy().memMsg) { - const ui = await forUiMessages(cached.messages); - ok(id, { messages: ui, cached: true }); - hydrateMedia(cached.messages, chatMid); - return; - } - - // Warm from disk immediately - if (!force) { - const disk = await loadDiskMessages(chatMid); - if (disk?.length) { - msgCache.set(chatMid, { at: Date.now(), messages: disk }); - emitEvent("messages", { - chatMid, - messages: await forUiMessages(disk), - cached: true, - }); - emitEvent("progress", { - scope: "messages", - chatMid, - state: "ready", - }); - hydrateMedia(disk, chatMid); - // still refresh below unless memory-fresh - } + bumpMediaEpoch(chatMid); + try { + let entry = msgCache.get(chatMid); + if (!entry) { + entry = await loadDiskMessages(chatMid) ?? undefined; + if (entry) msgCache.set(chatMid, entry); } - if (!force && cached?.messages?.length) { - emitEvent("messages", { - chatMid, - messages: await forUiMessages(cached.messages), - cached: true, - }); + if (!entry) { emitEvent("progress", { scope: "messages", chatMid, - state: "ready", + state: "loading", }); + entry = await seedMessageHistory(chatMid); + msgCache.set(chatMid, entry); + await saveDiskMessages(chatMid, entry); } - emitEvent("progress", { scope: "messages", chatMid, state: "loading" }); - - try { - let out: Json[] = []; - - // Prefer message boxes for DMs/bots (fast + works). Groups try Chat helper first. - let usedBoxes = false; - try { - if (chatMid.startsWith("c")) { - const chat = await client!.getChat(chatMid); - const messages = await chat.fetchMessages(limit); - for (const m of messages) { - out.push(await summarizeTalkMessage(m, { withMedia: false })); - } - } else { - usedBoxes = true; - } - } catch { - usedBoxes = true; - } - - if (usedBoxes || !out.length) { - let cursor = boxCursor.get(chatMid); - if (!cursor) { - const boxes = await client!.base.talk.getMessageBoxes({ - messageBoxListRequest: {}, - }); - for (const box of boxes.messageBoxes ?? []) { - const mid = String(box.id ?? ""); - storeBoxCursor(mid, box.lastDeliveredMessageId); - } - cursor = boxCursor.get(chatMid); - } - if (!cursor) { - msgCache.set(chatMid, { at: Date.now(), messages: [] }); - ok(id, { messages: [] }); - emitEvent("progress", { - scope: "messages", - chatMid, - state: "empty", - }); - return; - } - const messages = await client!.base.talk - .getPreviousMessagesV2WithRequest({ - request: { - messageBoxId: chatMid, - endMessageId: { - messageId: cursor.messageId, - deliveredTime: cursor.deliveredTime, - }, - messagesCount: limit, - }, - }); - out = []; - for (const raw of messages) { - try { - const meta = (raw as { contentMetadata?: Record }) - .contentMetadata; - if (meta?.BOT_CHECK || meta?.BOT_ORIGIN) { - const from = String((raw as { from?: string }).from ?? chatMid); - const cur = contactIndex.get(from); - if (cur) cur.kind = "bot"; - else { - contactIndex.set(from, { - name: contactIndex.get(from)?.name || from, - picturePath: null, - kind: "bot", - muted: false, - }); - } - } - out.push(await summarizeRawMessage(raw, { withMedia: false })); - } catch (e) { - console.error("[fetch msg skip]", e); - } - } - } - - out.sort((a, b) => Number(a.createdTime) - Number(b.createdTime)); - msgCache.set(chatMid, { at: Date.now(), messages: out }); - await saveDiskMessages(chatMid, out); - ok(id, { messages: await forUiMessages(out), cached: false }); - emitEvent("progress", { - scope: "messages", - chatMid, - state: out.length ? "ready" : "empty", - }); + const uiMessages = await forUiMessages(entry.messages); + ok(id, { + messages: uiMessages, + cached: !force, + historyComplete: !!entry.historyComplete, + }); + emitEvent("progress", { + scope: "messages", + chatMid, + state: entry.messages.length ? "ready" : "empty", + }); + void hydrateMedia(uiMessages.slice(-200), chatMid); - hydrateMedia(out, chatMid); - } catch (e) { - fail(id, e instanceof Error ? e.message : String(e)); + const sync = squareChatIndex.has(chatMid) || chatMid.startsWith("m") + ? () => syncSquareHistory(chatMid, entry!) + : () => syncTalkHistory(chatMid, entry!); + void dedupe(`history:${chatMid}`, sync).catch((error) => { + console.error("[history sync]", chatMid, error); emitEvent("progress", { scope: "messages", chatMid, state: "error", - error: String(e), + error: error instanceof Error ? error.message : String(error), }); - } - }); + }); + } catch (error) { + fail(id, error instanceof Error ? error.message : String(error)); + emitEvent("progress", { + scope: "messages", + chatMid, + state: "error", + error: String(error), + }); + } } async function doSend( @@ -609,6 +952,28 @@ export function createCommandService(runtime: CommandRuntime) { return; } + if (squareChatIndex.has(chatMid) || chatMid.startsWith("m")) { + try { + squareChatIndex.add(chatMid); + const chat = await client.getSquareChat(chatMid); + const sent = await chat.sendMessage(text); + const squareMessage = SquareMessage.fromRawTalk( + sent.createdSquareMessage, + client, + ); + const message = await summarizeSquareMessage(squareMessage, { + withMedia: false, + }); + await cacheMessageRow(chatMid, message); + runtime.setChatCache(null); + touchChatPreviewFromMessage(message); + ok(id, { message }); + } catch (e) { + fail(id, e instanceof Error ? e.message : String(e)); + } + return; + } + let sent: | { id?: unknown; createdTime?: unknown; contentType?: unknown } | null = null; @@ -642,7 +1007,7 @@ export function createCommandService(runtime: CommandRuntime) { text, ) ); - msgCache.delete(chatMid); + await cacheMessageRow(chatMid, message); runtime.setChatCache(null); touchChatPreviewFromMessage(message); ok(id, { message }); @@ -663,9 +1028,9 @@ export function createCommandService(runtime: CommandRuntime) { } } - msgCache.delete(chatMid); runtime.setChatCache(null); const message = sentMessagePayload(sent, chatMid, text); + await cacheMessageRow(chatMid, message); touchChatPreviewFromMessage(message); ok(id, { message }); } diff --git a/protocol/src/contacts.ts b/protocol/src/contacts.ts index 965c17d..2dafd75 100644 --- a/protocol/src/contacts.ts +++ b/protocol/src/contacts.ts @@ -11,6 +11,16 @@ export function profileUrl(picturePath?: string | null): string | null { }${picturePath}`; } +/** Convert an OpenChat OBS image hash into its public CDN object URL. */ +export function squareObsUrl(obsHash?: string | null): string | null { + const value = obsHash?.trim(); + if (!value) return null; + if (value.startsWith("http")) return value; + // Appending a profile-style resize suffix makes current Square OBS objects + // return HTTP 400. The hash itself is the complete public object path. + return `https://obs.line-scdn.net/${value}`; +} + export function picturePathOf( profile: Record | null | undefined, ): string | null { diff --git a/protocol/src/h264.ts b/protocol/src/h264.ts new file mode 100644 index 0000000..8369640 --- /dev/null +++ b/protocol/src/h264.ts @@ -0,0 +1,141 @@ +/** Incremental Annex-B parser for the raw H.264 stream produced by wf-recorder. */ +export class AnnexBParser { + #pending = new Uint8Array(0); + + push(chunk: Uint8Array): Uint8Array[] { + if (!chunk.length) return []; + const merged = new Uint8Array(this.#pending.length + chunk.length); + merged.set(this.#pending, 0); + merged.set(chunk, this.#pending.length); + + const starts = findStartCodes(merged); + if (starts.length < 2) { + this.#pending = merged; + return []; + } + + const out: Uint8Array[] = []; + for (let i = 0; i + 1 < starts.length; i++) { + const [start, prefix] = starts[i]!; + const [end] = starts[i + 1]!; + const nal = trimTrailingZeros(merged.subarray(start + prefix, end)); + if (nal.length) out.push(nal.slice()); + } + this.#pending = merged.subarray(starts.at(-1)![0]).slice(); + return out; + } + + flush(): Uint8Array[] { + const starts = findStartCodes(this.#pending); + if (!starts.length) { + this.#pending = new Uint8Array(0); + return []; + } + const [start, prefix] = starts.at(-1)!; + const nal = trimTrailingZeros(this.#pending.subarray(start + prefix)); + this.#pending = new Uint8Array(0); + return nal.length ? [nal.slice()] : []; + } +} + +/** Groups the encoder's NAL units into access units (video frames). + * wf-recorder/x264 is started with AUD enabled and one slice per frame. The + * VCL fallback also keeps the parser useful when AUD is missing. + */ +export class H264AccessUnitAssembler { + #pending: Uint8Array[] = []; + #hasVcl = false; + + push(nal: Uint8Array): Uint8Array[][] { + if (!nal.length) return []; + const out: Uint8Array[][] = []; + const kind = nal[0]! & 0x1f; + const isVcl = kind === 1 || kind === 5; + const startsNextUnit = kind === 9 || + (this.#hasVcl && (isVcl || kind === 6 || kind === 7 || kind === 8)); + + if (startsNextUnit && this.#pending.length) { + out.push(this.#take()); + } + this.#pending.push(nal); + if (isVcl) this.#hasVcl = true; + return out; + } + + flush(): Uint8Array[][] { + return this.#pending.length ? [this.#take()] : []; + } + + #take(): Uint8Array[] { + const current = this.#pending; + this.#pending = []; + this.#hasVcl = false; + return current; + } +} + +export type H264RtpPayload = { + payload: Uint8Array; + endOfFrame: boolean; +}; + +/** Packetize one H.264 access unit using RFC 6184 single-NAL/FU-A payloads. */ +export function packetizeH264AccessUnit( + nals: Uint8Array[], + maxPayload = 1100, +): H264RtpPayload[] { + const packets = nals.flatMap((nal) => packetizeH264Nal(nal, maxPayload)); + return packets.map((payload, index) => ({ + payload, + endOfFrame: index === packets.length - 1, + })); +} + +export function packetizeH264Nal( + nal: Uint8Array, + maxPayload = 1100, +): Uint8Array[] { + if (!nal.length) return []; + if (maxPayload < 3) throw new Error("H.264 RTP payload limit is too small"); + if (nal.length <= maxPayload) return [nal.slice()]; + + const header = nal[0]!; + const fuIndicator = (header & 0xe0) | 28; + const nalType = header & 0x1f; + const chunkSize = maxPayload - 2; + const out: Uint8Array[] = []; + for (let offset = 1; offset < nal.length; offset += chunkSize) { + const end = Math.min(nal.length, offset + chunkSize); + const payload = new Uint8Array(2 + end - offset); + payload[0] = fuIndicator; + payload[1] = nalType; + if (offset === 1) payload[1] |= 0x80; + if (end === nal.length) payload[1] |= 0x40; + payload.set(nal.subarray(offset, end), 2); + out.push(payload); + } + return out; +} + +function findStartCodes(bytes: Uint8Array): Array<[number, number]> { + const out: Array<[number, number]> = []; + for (let i = 0; i + 2 < bytes.length; i++) { + if (bytes[i] !== 0 || bytes[i + 1] !== 0) continue; + if (bytes[i + 2] === 1) { + out.push([i, 3]); + i += 2; + } else if ( + i + 3 < bytes.length && bytes[i + 2] === 0 && bytes[i + 3] === 1 + ) { + out.push([i, 4]); + i += 3; + } + } + return out; +} + +function trimTrailingZeros(bytes: Uint8Array): Uint8Array { + let end = bytes.length; + while (end > 0 && bytes[end - 1] === 0) end--; + return bytes.subarray(0, end); +} diff --git a/protocol/src/listener.ts b/protocol/src/listener.ts index 4da794a..1142315 100644 --- a/protocol/src/listener.ts +++ b/protocol/src/listener.ts @@ -1,5 +1,6 @@ -import type { Client, TalkMessage } from "@evex/linejs"; +import type { Client, SquareMessage, TalkMessage } from "@evex/linejs"; import * as calls from "./calls.ts"; +import { talkChatMid } from "./messages.ts"; type Json = Record; @@ -7,11 +8,15 @@ export type ListenerRuntime = { getClient: () => Client | null; isListening: () => boolean; setListening: (listening: boolean) => void; - invalidateMessages: (chatMid: string) => void; + cacheMessage: (chatMid: string, message: Json) => Promise; summarizeTalkMessage: ( message: TalkMessage, opts?: { withMedia?: boolean }, ) => Promise; + summarizeSquareMessage: ( + message: SquareMessage, + opts?: { withMedia?: boolean }, + ) => Promise; storeBoxCursor: ( mid: string, value?: { messageId?: unknown; deliveredTime?: unknown } | null, @@ -29,12 +34,23 @@ export type ListenerRuntime = { export function createListener(runtime: ListenerRuntime) { const { summarizeTalkMessage, + summarizeSquareMessage, storeBoxCursor, upsertChatFromMessage, hydrateLiveMedia, upsertChatFromContact, emitEvent, } = runtime; + const seenLiveMessageIds = new Set(); + + function isDuplicateLiveMessage(message: Json): boolean { + const id = String(message.id ?? ""); + if (!id) return false; + if (seenLiveMessageIds.has(id)) return true; + if (seenLiveMessageIds.size >= 4096) seenLiveMessageIds.clear(); + seenLiveMessageIds.add(id); + return false; + } function startListen() { const client = runtime.getClient(); @@ -46,8 +62,13 @@ export function createListener(runtime: ListenerRuntime) { try { const tm = msg as TalkMessage; const message = await summarizeTalkMessage(tm, { withMedia: false }); - const peer = message.mine ? String(message.to) : String(message.from); - runtime.invalidateMessages(peer); + if (isDuplicateLiveMessage(message)) return; + const peer = talkChatMid(message); + if (!peer) return; + message.chatMid = peer; + void runtime.cacheMessage(peer, message).catch((error) => + console.error("[cache live message]", error) + ); // Advance box cursor so later refetch can see this message. storeBoxCursor(peer, { messageId: message.id, @@ -93,6 +114,24 @@ export function createListener(runtime: ListenerRuntime) { } }); + client.on("square:message", async (msg) => { + try { + const message = await summarizeSquareMessage(msg, { + withMedia: false, + }); + if (isDuplicateLiveMessage(message)) return; + const chatMid = String(message.chatMid ?? message.to ?? ""); + if (!chatMid) return; + void runtime.cacheMessage(chatMid, message).catch((error) => + console.error("[cache live openchat message]", error) + ); + emitEvent("message", { message }); + await upsertChatFromMessage(message); + } catch (e) { + console.error("[listen openchat message]", e); + } + }); + client.on( "call:incoming", calls.handleIncomingCall, @@ -102,7 +141,7 @@ export function createListener(runtime: ListenerRuntime) { calls.handleCallCancel, ); - client.listen({ talk: true, square: false }); + client.listen({ talk: true, square: true }); emitEvent("listening"); } diff --git a/protocol/src/main.ts b/protocol/src/main.ts index 05fa89d..9fa3576 100644 --- a/protocol/src/main.ts +++ b/protocol/src/main.ts @@ -3,14 +3,24 @@ * NDJSON over stdin/stdout. Heavy I/O is cached + async so GTK stays snappy. */ -import { Client, loginWithAuthToken, TalkMessage } from "@evex/linejs"; +import { + Client, + loginWithAuthToken, + SquareMessage, + TalkMessage, +} from "@evex/linejs"; import { FileStorage } from "@evex/linejs/storage"; import { fromFileUrl, join } from "@std/path"; import { atomicWriteJson, ensurePrivateDir } from "./storage.ts"; import { AuthStore, LineDevice } from "./auth.ts"; import { CachePolicy, policyFor } from "./cache_policy.ts"; -import { picturePathOf, profileUrl } from "./contacts.ts"; -import { coerceI64, normalizeRaw } from "./messages.ts"; +import { picturePathOf, profileUrl, squareObsUrl } from "./contacts.ts"; +import { + coerceI64, + normalizeRaw, + sticonResources, + talkChatMid, +} from "./messages.ts"; import * as calls from "./calls.ts"; import { createOutgoingMedia } from "./outgoing_media.ts"; import { createMediaCache } from "./media_cache.ts"; @@ -31,6 +41,7 @@ type ChatRow = { unread: number; preview: string; muted?: boolean; + pinned?: boolean; }; const dataDir = Deno.env.get("LINE_GTK_DATA") ?? @@ -53,6 +64,7 @@ const LINE_VERSION = Deno.env.get("LINE_VERSION")?.trim() || "26.6.2"; const chatCachePath = join(cacheDir, "chats.json"); const contactCachePath = join(cacheDir, "contacts.json"); const msgDiskDir = join(cacheDir, "messages"); +const MESSAGE_CACHE_SCHEMA = 3; await ensurePrivateDir(msgDiskDir); let cacheRetention = (Deno.env.get("LINE_GTK_CACHE_RETENTION") || "smart") @@ -221,11 +233,12 @@ const stickers = createStickerService({ cacheUrl, existingImage, sentMessagePayload, - invalidateMessages: (chatMid) => msgCache.delete(chatMid), + invalidateMessages: markMessageCacheStale, invalidateChats: () => { chatCache = null; }, touchChatPreviewFromMessage, + emitEvent, ok, fail, }); @@ -248,7 +261,7 @@ const outgoingMedia = createOutgoingMedia({ isMineFrom, normType, getCachedMessages: (chatMid) => msgCache.get(chatMid)?.messages, - invalidateMessages: (chatMid) => msgCache.delete(chatMid), + invalidateMessages: markMessageCacheStale, invalidateChats: () => { chatCache = null; }, @@ -260,11 +273,37 @@ const outgoingMedia = createOutgoingMedia({ }); let chatCache: { at: number; chats: ChatRow[] } | null = null; -const msgCache = new Map(); +type MessageHistoryCursor = { + messageId: string; + deliveredTime: string; +}; +type MessageCacheEntry = { + at: number; + messages: Json[]; + historyComplete?: boolean; + oldestCursor?: MessageHistoryCursor; + squareSyncToken?: string; +}; +const msgCache = new Map(); const inFlight = new Map>(); const contactIndex = new Map< string, - { name: string; picturePath: string | null; kind: string; muted: boolean } + { + name: string; + picturePath: string | null; + statusMessage: string; + kind: string; + muted: boolean; + } +>(); +const squareChatIndex = new Set(); +const squareMemberIndex = new Map< + string, + { + name: string; + picturePath: string | null; + statusMessage: string; + } >(); const mediaEpoch = new Map(); type BoxCursor = { @@ -279,8 +318,9 @@ const listener = createListener({ setListening: (next) => { listening = next; }, - invalidateMessages: (chatMid) => msgCache.delete(chatMid), + cacheMessage: cacheLiveMessage, summarizeTalkMessage, + summarizeSquareMessage, storeBoxCursor, upsertChatFromMessage, hydrateLiveMedia, @@ -303,6 +343,7 @@ const commands = createCommandService({ }, msgCache, contactIndex, + squareChatIndex, boxCursor, avatarDir, chatCachePath, @@ -324,6 +365,7 @@ const commands = createCommandService({ forUiMessages, hydrateMedia, summarizeTalkMessage, + summarizeSquareMessage, summarizeRawMessage: (raw, opts) => summarizeRawMessage( raw as Parameters[0], @@ -410,6 +452,20 @@ function normType(t: unknown): string { return String(t ?? "NONE").toUpperCase(); } +function sticonSticker(meta: Record): { + stickerId: string; + packageId: string; + rawSticonId: string; +} | null { + const first = sticonResources(meta)[0]; + if (!first) return null; + return { + stickerId: `sticon:${first.sticonId}`, + packageId: first.productId, + rawSticonId: first.sticonId, + }; +} + async function fromRawTalkSafe(raw: unknown): Promise { const normalized = normalizeRaw(raw as Record); return await TalkMessage.fromRawTalk( @@ -418,6 +474,9 @@ async function fromRawTalkSafe(raw: unknown): Promise { ); } +let lastE2eeFallbackLog = 0; +let e2eeFallbackSuppressed = 0; + function patchE2eeGuards() { if (!client) return; const e2ee = client.base.e2ee as unknown as { @@ -431,7 +490,16 @@ function patchE2eeGuards() { try { return await orig(msg); } catch (e) { - console.error("[e2ee decrypt fallback]", e); + e2eeFallbackSuppressed++; + const now = Date.now(); + if (now - lastE2eeFallbackLog > 5_000) { + console.error("[e2ee decrypt fallback]", { + error: e instanceof Error ? e.message : String(e), + suppressed: Math.max(0, e2eeFallbackSuppressed - 1), + }); + lastE2eeFallbackLog = now; + e2eeFallbackSuppressed = 0; + } return msg; } }; @@ -540,6 +608,66 @@ function isMineFrom(from: unknown): boolean { return !!mid && !!f && f === mid; } +const reactionKinds = [ + "ALL", + "UNDO", + "NICE", + "LOVE", + "FUN", + "AMAZING", + "SAD", + "OMG", +]; + +function reactionKind(value: unknown): string { + if (typeof value === "string") { + const upper = value.toUpperCase(); + if (reactionKinds.includes(upper)) return upper; + if (/^\d+$/.test(value)) return reactionKinds[Number(value)] ?? ""; + } + if (typeof value === "number") return reactionKinds[value] ?? ""; + return ""; +} + +function summarizeTalkReactions(value: unknown): Json[] { + if (!Array.isArray(value)) return []; + const counts = new Map(); + for (const row of value as Array>) { + const type = reactionKind( + (row.reactionType as Record | undefined) + ?.predefinedReactionType, + ); + if (!type || type === "ALL" || type === "UNDO") continue; + const previous = counts.get(type) ?? { count: 0, mine: false }; + previous.count++; + previous.mine ||= String(row.fromUserMid ?? "") === myMid(); + counts.set(type, previous); + } + return [...counts].map(([kind, summary]) => ({ kind, ...summary })); +} + +function summarizeSquareReactions(value: unknown): Json[] { + const status = value as + | { + countByReactionType?: Record; + myReaction?: { type?: unknown }; + } + | null + | undefined; + if (!status?.countByReactionType) return []; + const mine = reactionKind(status.myReaction?.type); + const rows: Json[] = []; + for ( + const [rawType, rawCount] of Object.entries(status.countByReactionType) + ) { + const kind = reactionKind(rawType); + const count = Number(rawCount ?? 0); + if (!kind || kind === "ALL" || kind === "UNDO" || count <= 0) continue; + rows.push({ kind, count, mine: mine === kind }); + } + return rows; +} + async function myProfilePayload(profile: { mid: string; displayName?: string; @@ -571,6 +699,60 @@ async function myProfilePayload(profile: { }; } +function contactStatusName(status: unknown): string { + if (typeof status === "string") return status; + return ({ + 0: "UNSPECIFIED", + 1: "FRIEND", + 2: "FRIEND_BLOCKED", + 3: "RECOMMEND", + 4: "RECOMMEND_BLOCKED", + 5: "DELETED", + 6: "DELETED_BLOCKED", + } as Record)[Number(status)] ?? "UNSPECIFIED"; +} + +async function contactRelationPayload(mid: string) { + if (!client) throw new Error("not_logged_in"); + if (!mid.startsWith("u")) throw new Error("profile_not_line_user"); + + const contact = await client.base.talk.getContact({ mid }); + const status = contactStatusName(contact.status); + const isFriend = status === "FRIEND" || status === "FRIEND_BLOCKED"; + const blocked = status === "FRIEND_BLOCKED" || + status === "RECOMMEND_BLOCKED" || status === "DELETED_BLOCKED"; + const picturePath = contact.picturePath || null; + let avatarPath: string | null = null; + try { + avatarPath = await avatarPathFor(mid, picturePath); + } catch (error) { + console.error("[profile relation avatar]", error); + } + + contactIndex.set(mid, { + name: contact.displayName || mid, + picturePath, + statusMessage: contact.statusMessage ?? "", + kind: /BOT/i.test(String(contact.type ?? "")) ? "bot" : "dm", + muted: !!(contact as { notificationDisabled?: boolean }) + .notificationDisabled, + }); + await saveDiskContacts(); + + return { + mid, + displayName: contact.displayName || mid, + statusMessage: contact.statusMessage ?? "", + picturePath, + avatarPath, + status, + isFriend, + blocked, + canAdd: !isFriend && !blocked, + canChat: isFriend, + }; +} + async function loadDiskContacts() { if (contactIndex.size > 0) return; try { @@ -581,6 +763,7 @@ async function loadDiskContacts() { raw.contacts as Record, @@ -589,6 +772,7 @@ async function loadDiskContacts() { contactIndex.set(mid, { name: c.name, picturePath: c.picturePath ?? null, + statusMessage: c.statusMessage ?? "", kind: c.kind || "dm", muted: !!c.muted, }); @@ -601,7 +785,13 @@ async function loadDiskContacts() { async function saveDiskContacts() { const contacts: Record< string, - { name: string; picturePath: string | null; kind: string; muted: boolean } + { + name: string; + picturePath: string | null; + statusMessage: string; + kind: string; + muted: boolean; + } > = {}; for (const [mid, c] of contactIndex) contacts[mid] = c; try { @@ -611,27 +801,168 @@ async function saveDiskContacts() { } } -async function loadDiskMessages(chatMid: string): Promise { +async function loadDiskMessages( + chatMid: string, +): Promise { try { - await refreshCachePolicy(); const raw = JSON.parse( await Deno.readTextFile(join(msgDiskDir, `${chatMid}.json`)), ); - if (Date.now() - Number(raw.at || 0) > cachePolicy().diskMsg) return null; - if (Array.isArray(raw.messages)) return raw.messages as Json[]; + const schema = Number(raw.schema ?? 0); + if ( + (schema === 0 || schema === 2 || schema === MESSAGE_CACHE_SCHEMA) && + Array.isArray(raw.messages) + ) { + const messages = raw.messages as Json[]; + const oldest = messages[0]; + return { + at: Number(raw.at || 0), + messages, + historyComplete: schema === MESSAGE_CACHE_SCHEMA && + !!raw.historyComplete, + oldestCursor: raw.oldestCursor ?? (oldest + ? { + messageId: String(oldest.id ?? ""), + deliveredTime: String(oldest.createdTime ?? "0"), + } + : undefined), + squareSyncToken: typeof raw.squareSyncToken === "string" + ? raw.squareSyncToken + : undefined, + }; + } } catch { /* miss */ } return null; } -async function saveDiskMessages(chatMid: string, messages: Json[]) { - try { +const messageWriteQueues = new Map>(); +const liveMessageSaveTimers = new Map>(); + +async function saveDiskMessages(chatMid: string, entry: MessageCacheEntry) { + const snapshot: MessageCacheEntry = { + ...entry, + messages: entry.messages.map((message) => ({ ...message })), + }; + const previous = messageWriteQueues.get(chatMid) ?? Promise.resolve(); + const write = previous.catch(() => {}).then(async () => { await atomicWriteJson(join(msgDiskDir, `${chatMid}.json`), { - at: Date.now(), - messages, + schema: MESSAGE_CACHE_SCHEMA, + at: snapshot.at, + historyComplete: !!snapshot.historyComplete, + oldestCursor: snapshot.oldestCursor ?? null, + squareSyncToken: snapshot.squareSyncToken ?? null, + messages: snapshot.messages, }); + }); + messageWriteQueues.set(chatMid, write); + try { + await write; } catch (error) { console.error("[messages-cache]", chatMid, error); + } finally { + if (messageWriteQueues.get(chatMid) === write) { + messageWriteQueues.delete(chatMid); + } + } +} + +async function cacheLiveMessage(chatMid: string, message: Json) { + let entry = msgCache.get(chatMid); + if (!entry) entry = await loadDiskMessages(chatMid) ?? undefined; + entry ??= { at: Date.now(), messages: [], historyComplete: false }; + const id = String(message.id ?? ""); + const index = id + ? entry.messages.findIndex((row) => String(row.id ?? "") === id) + : -1; + if (index >= 0) { + entry.messages[index] = { ...entry.messages[index], ...message }; + } else { + entry.messages.push(message); + } + entry.messages.sort((a, b) => + Number(a.createdTime ?? 0) - Number(b.createdTime ?? 0) + ); + entry.at = Date.now(); + msgCache.set(chatMid, entry); + const pending = liveMessageSaveTimers.get(chatMid); + if (pending !== undefined) clearTimeout(pending); + liveMessageSaveTimers.set( + chatMid, + setTimeout(() => { + liveMessageSaveTimers.delete(chatMid); + const latest = msgCache.get(chatMid); + if (latest) void saveDiskMessages(chatMid, latest); + }, 350), + ); +} + +function markMessageCacheStale(chatMid: string) { + const entry = msgCache.get(chatMid); + if (entry) entry.at = 0; +} + +async function messageCacheEntry(chatMid: string): Promise { + let entry = msgCache.get(chatMid); + if (!entry) entry = await loadDiskMessages(chatMid) ?? undefined; + entry ??= { at: Date.now(), messages: [], historyComplete: false }; + return entry; +} + +async function publishMessageMutation( + chatMid: string, + entry: MessageCacheEntry, +) { + entry.at = Date.now(); + msgCache.set(chatMid, entry); + await saveDiskMessages(chatMid, entry); + emitEvent("messages", { + chatMid, + messages: await forUiMessages(entry.messages), + cached: false, + historyComplete: !!entry.historyComplete, + }); +} + +async function cacheOwnReaction( + chatMid: string, + messageId: string, + reaction: string, +) { + const entry = await messageCacheEntry(chatMid); + const message = entry.messages.find((row) => + String(row.id ?? "") === messageId + ); + if (!message) return; + const rows = Array.isArray(message.reactions) + ? (message.reactions as Json[]).map((row) => ({ ...row })) + : []; + for (let index = rows.length - 1; index >= 0; index--) { + if (!rows[index]?.mine) continue; + rows[index]!.count = Math.max(0, Number(rows[index]!.count ?? 0) - 1); + rows[index]!.mine = false; + if (Number(rows[index]!.count) === 0) rows.splice(index, 1); + } + if (reaction !== "UNDO") { + const current = rows.find((row) => String(row.kind ?? "") === reaction); + if (current) { + current.count = Number(current.count ?? 0) + 1; + current.mine = true; + } else { + rows.push({ kind: reaction, count: 1, mine: true }); + } } + message.reactions = rows; + await publishMessageMutation(chatMid, entry); +} + +async function removeCachedMessage(chatMid: string, messageId: string) { + const entry = await messageCacheEntry(chatMid); + entry.messages = entry.messages.filter((row) => + String(row.id ?? "") !== messageId + ); + await publishMessageMutation(chatMid, entry); + const latest = entry.messages.at(-1); + if (latest) touchChatPreviewFromMessage(latest); } let contactsAt = 0; @@ -671,6 +1002,7 @@ async function refreshContactIndex(force = false) { contactIndex.set(mid, { name: c.displayName || mid, picturePath: c.picturePath ?? null, + statusMessage: c.statusMessage ?? "", kind, muted: !!(c as { notificationDisabled?: boolean }) .notificationDisabled, @@ -685,6 +1017,7 @@ async function refreshContactIndex(force = false) { contactIndex.set(mid, { name: c.displayName || mid, picturePath: c.picturePath ?? null, + statusMessage: c.statusMessage ?? "", kind: /BOT/i.test(type) ? "bot" : "dm", muted: !!(c as { notificationDisabled?: boolean }) .notificationDisabled, @@ -699,6 +1032,146 @@ async function refreshContactIndex(force = false) { await saveDiskContacts(); } +async function senderProfilePayload(mid: string): Promise { + if (!mid) { + return { + senderName: "", + senderAvatarPath: null, + senderStatusMessage: "", + senderKind: "line", + }; + } + const info = await resolveContactInfo(mid); + let avatarPath: string | null = null; + try { + avatarPath = await avatarPathFor(mid, info.picturePath); + } catch (e) { + console.error("[sender avatar]", mid, e); + } + return { + senderName: info.name, + senderAvatarPath: avatarPath, + senderStatusMessage: info.statusMessage, + senderKind: "line", + }; +} + +async function resolveSquareMemberInfo(mid: string): Promise<{ + name: string; + picturePath: string | null; + statusMessage: string; +}> { + const cached = squareMemberIndex.get(mid); + if (cached) return cached; + const fallback = { + name: mid, + picturePath: null, + statusMessage: "", + }; + if (!client || !mid) return fallback; + try { + const response = await client.base.square.getSquareMember({ + squareMemberMid: mid, + }); + const member = response.squareMember; + const info = { + name: member.displayName || mid, + picturePath: squareObsUrl(member.profileImageObsHash), + statusMessage: member.selfIntroduction || "", + }; + squareMemberIndex.set(mid, info); + return info; + } catch (e) { + console.error("[openchat member]", mid, e); + return fallback; + } +} + +async function summarizeSquareMessage( + sm: SquareMessage, + _opts: { withMedia?: boolean } = {}, +): Promise { + const raw = sm.raw; + const message = raw.message; + const id = String(message.id ?? ""); + const from = String(message.from ?? ""); + const chatMid = String(message.to ?? ""); + let contentType = normType(message.contentType); + const meta = (message.contentMetadata ?? {}) as Record; + const member = await resolveSquareMemberInfo(from); + let avatarPath: string | null = null; + try { + avatarPath = await avatarPathFor(from, member.picturePath); + } catch (e) { + console.error("[openchat avatar]", from, e); + } + let mine = false; + try { + mine = await sm.isMyMessage(); + } catch { /* keep false */ } + let text = sm.text || meta.ALT_TEXT || meta.STKTXT || ""; + let imagePath: string | null = null; + let imageUrl: string | null = null; + let stickerId = meta.STKID || ""; + let stickerPackageId = meta.STKPKGID || ""; + let rawSticonId = ""; + const sticon = contentType === "NONE" ? sticonSticker(meta) : null; + if (sticon) { + contentType = "STICKER"; + stickerId = sticon.stickerId; + stickerPackageId = sticon.packageId; + rawSticonId = sticon.rawSticonId; + text = "[Sticker]"; + } + if (contentType === "STICKER" && stickerId) { + text = text || "[Sticker]"; + if (rawSticonId) { + imageUrl = stickers.sticonUrl(stickerPackageId, rawSticonId); + imagePath = await stickers.ensureSticon(stickerPackageId, rawSticonId); + } else { + imageUrl = stickers.animationUrl(stickerId); + imagePath = await stickers.ensureImage(stickerId); + } + } else if (contentType === "IMAGE") { + text = text || "[Image]"; + imageUrl = client?.base.obs.getMessageDataUrl(id, true, true) ?? null; + } else if (contentType === "VIDEO") { + text = text || "[Video]"; + } else if (contentType === "AUDIO") { + text = text || "Voice message"; + } else if (!text && contentType !== "NONE") { + text = `[${contentType}]`; + } + return { + id, + text, + from, + to: chatMid, + chatMid, + mine, + createdTime: Number(message.createdTime ?? 0), + contentType, + imagePath, + imageUrl, + audioPath: null, + fileName: meta.FILE_NAME || meta.FILENAME || null, + filePath: null, + stickerId: stickerId || null, + stickerPackageId: stickerPackageId || null, + flex: contentType === "FLEX" ? extractFlex(meta) : null, + durationMs: meta.DURATION ? Number(meta.DURATION) : null, + needsMedia: contentType === "STICKER" ? !imagePath : false, + senderName: member.name, + senderAvatarPath: avatarPath, + senderStatusMessage: member.statusMessage, + senderKind: "openchat", + reactions: summarizeSquareReactions( + (raw as unknown as { messageReactionStatus?: unknown }) + .messageReactionStatus, + ), + }; +} + async function summarizeTalkMessage( tm: TalkMessage, opts: { withMedia?: boolean } = {}, @@ -711,15 +1184,28 @@ async function summarizeTalkMessage( createdTime?: number | string | bigint; contentType?: string | number; contentMetadata?: Record; + reactions?: unknown; }; - const contentType = normType(raw.contentType); + let contentType = normType(raw.contentType); const meta = raw.contentMetadata ?? {}; let text = tm.text || meta.ALT_TEXT || meta.STKTXT || ""; let imagePath: string | null = null; let imageUrl: string | null = meta.PREVIEW_URL || meta.DOWNLOAD_URL || null; const id = String(raw.id ?? ""); - const stkId = meta.STKID || ""; - const stkPkg = meta.STKPKGID || ""; + const reactions = summarizeTalkReactions(raw.reactions); + let stkId = meta.STKID || ""; + let stkPkg = meta.STKPKGID || ""; + let rawSticonId = ""; + const sticon = contentType === "NONE" ? sticonSticker(meta) : null; + if (sticon) { + contentType = "STICKER"; + stkId = sticon.stickerId; + stkPkg = sticon.packageId; + rawSticonId = sticon.rawSticonId; + text = "[Sticker]"; + imageUrl = stickers.sticonUrl(stkPkg, rawSticonId); + } + const sender = await senderProfilePayload(String(raw.from ?? "")); if (contentType === "AUDIO") { text = text || "Voice message"; @@ -770,6 +1256,7 @@ async function summarizeTalkMessage( } const flex = null; return { + ...sender, id, text: duration ? `Voice message (${Math.round(Number(duration) / 1000)}s)` @@ -777,6 +1264,11 @@ async function summarizeTalkMessage( from: raw.from ?? "", to: raw.to ?? "", mine: isMineFrom(raw.from), + chatMid: talkChatMid({ + from: raw.from, + to: raw.to, + mine: isMineFrom(raw.from), + }), createdTime: Number(raw.createdTime ?? 0), contentType, imagePath: null, @@ -788,6 +1280,7 @@ async function summarizeTalkMessage( flex, durationMs: duration ? Number(duration) : null, needsMedia: !audioPath, + reactions, }; } @@ -872,9 +1365,16 @@ async function summarizeTalkMessage( } else if (contentType === "STICKER") { text = text || "[Sticker]"; if (stkId) { - imageUrl = stickers.animationUrl(stkId); - if (withMedia) { - imagePath = await stickers.ensureImage(stkId); + if (rawSticonId) { + imageUrl = stickers.sticonUrl(stkPkg, rawSticonId); + if (withMedia) { + imagePath = await stickers.ensureSticon(stkPkg, rawSticonId); + } + } else { + imageUrl = stickers.animationUrl(stkId); + if (withMedia) { + imagePath = await stickers.ensureImage(stkId); + } } } } else if (contentType === "FLEX") { @@ -886,11 +1386,17 @@ async function summarizeTalkMessage( const flex = contentType === "FLEX" ? extractFlex(meta) : null; return { + ...sender, id, text, from: raw.from ?? "", to: raw.to ?? "", mine: isMineFrom(raw.from), + chatMid: talkChatMid({ + from: raw.from, + to: raw.to, + mine: isMineFrom(raw.from), + }), createdTime: Number(raw.createdTime ?? 0), contentType, imagePath, @@ -908,6 +1414,7 @@ async function summarizeTalkMessage( : contentType === "AUDIO" ? true : false, + reactions, }; } @@ -922,8 +1429,19 @@ async function summarizeRawMessage( console.error("[summarizeRawMessage]", e); const r = normalizeRaw(raw as unknown as Record); const meta = (r.contentMetadata ?? {}) as Record; - const contentType = normType(r.contentType); + let contentType = normType(r.contentType); + let stickerId = meta.STKID || ""; + let stickerPackageId = meta.STKPKGID || ""; + let imageUrl: string | null = meta.PREVIEW_URL || meta.DOWNLOAD_URL || null; + const sticon = contentType === "NONE" ? sticonSticker(meta) : null; + if (sticon) { + contentType = "STICKER"; + stickerId = sticon.stickerId; + stickerPackageId = sticon.packageId; + imageUrl = stickers.sticonUrl(stickerPackageId, sticon.rawSticonId); + } let text = String(r.text ?? meta.ALT_TEXT ?? meta.STKTXT ?? ""); + if (sticon) text = "[Sticker]"; if (!text) { if (contentType === "AUDIO") text = "Voice message"; else if (contentType === "IMAGE") text = "[Image]"; @@ -934,24 +1452,31 @@ async function summarizeRawMessage( else if (contentType !== "NONE") text = `[${contentType}]`; } return { + ...(await senderProfilePayload(String(r.from ?? ""))), id: String(r.id ?? ""), text, from: String(r.from ?? ""), to: String(r.to ?? ""), mine: isMineFrom(r.from), + chatMid: talkChatMid({ + from: r.from, + to: r.to, + mine: isMineFrom(r.from), + }), createdTime: Number(r.createdTime ?? 0), contentType, imagePath: null, - imageUrl: meta.PREVIEW_URL || meta.DOWNLOAD_URL || null, + imageUrl, audioPath: null, fileName: meta.FILE_NAME || meta.FILENAME || null, filePath: null, - stickerId: meta.STKID || null, - stickerPackageId: meta.STKPKGID || null, + stickerId: stickerId || null, + stickerPackageId: stickerPackageId || null, flex: contentType === "FLEX" ? extractFlex(meta) : null, durationMs: meta.DURATION ? Number(meta.DURATION) : null, needsMedia: contentType === "IMAGE" || contentType === "VIDEO" || contentType === "STICKER" || contentType === "AUDIO", + reactions: summarizeTalkReactions(r.reactions), }; } } @@ -976,13 +1501,29 @@ async function hydrateMedia( const first = pending.slice(0, MEDIA_FIRST_BATCH); const rest = pending.slice(MEDIA_FIRST_BATCH); + let retryStateDirty = false; const failOne = (id: string) => { if (!stdoutAlive || workGen !== gen) return; if (!epochOk()) return; + const retryAfter = Date.now() + 6 * 60 * 60 * 1000; + const cached = msgCache.get(chatMid); + const row = cached?.messages.find((message) => String(message.id) === id); + if (row) { + row.mediaRetryAfter = retryAfter; + row.needsMedia = false; + retryStateDirty = true; + } emitEvent("media_failed", { chatMid, messageId: id }); }; + const persistRetryState = async () => { + if (!retryStateDirty) return; + retryStateDirty = false; + const cached = msgCache.get(chatMid); + if (cached) await saveDiskMessages(chatMid, cached); + }; + const work = async (m: Json) => { if (!client || !stdoutAlive || workGen !== gen) return; if (!epochOk()) return; @@ -998,7 +1539,15 @@ async function hydrateMedia( return; } if (!epochOk()) return; - path = await stickers.ensureImage(stkId); + if (stkId.startsWith("sticon:")) { + const productId = String(m.stickerPackageId || ""); + path = await stickers.ensureSticon( + productId, + stkId.slice("sticon:".length), + ); + } else { + path = await stickers.ensureImage(stkId); + } if (path) { emitEvent("media_ready", { chatMid, messageId: id, imagePath: path }); } else { @@ -1048,6 +1597,7 @@ async function hydrateMedia( if (row) { row.audioPath = audioPath; row.needsMedia = false; + delete row.mediaRetryAfter; } } emitEvent("media_ready", { @@ -1156,6 +1706,7 @@ async function hydrateMedia( if (row) { row.imagePath = m.imagePath; row.needsMedia = false; + delete row.mediaRetryAfter; if (m.filePath) row.filePath = m.filePath; } } @@ -1191,9 +1742,13 @@ async function hydrateMedia( }; await mapPool(first, MEDIA_CONCURRENCY, (m) => work(m)); - if (!epochOk()) return; + if (!epochOk()) { + await persistRetryState(); + return; + } await sleep(120); await mapPool(rest, MEDIA_CONCURRENCY, (m) => work(m)); + await persistRetryState(); } async function hydratePreviews(chats: ChatRow[]) { @@ -1361,15 +1916,27 @@ function touchChatPreviewFromMessage(message: Json) { async function resolveContactInfo(mid: string): Promise<{ name: string; picturePath: string | null; + statusMessage: string; kind: string; muted: boolean; }> { + if (mid === myMid() && client?.base.profile) { + const profile = client.base.profile; + return { + name: profile.displayName || mid, + picturePath: profile.picturePath ?? null, + statusMessage: profile.statusMessage ?? "", + kind: "self", + muted: false, + }; + } const cached = contactIndex.get(mid); if (cached?.name && cached.name !== mid) return cached; if (!client) { return cached ?? { name: mid, picturePath: null, + statusMessage: "", kind: mid.startsWith("c") ? "group" : "dm", muted: false, }; @@ -1380,6 +1947,7 @@ async function resolveContactInfo(mid: string): Promise<{ const info = { name: c.displayName || mid, picturePath: c.picturePath ?? null, + statusMessage: c.statusMessage ?? "", kind: /BOT/i.test(type) ? "bot" : mid.startsWith("c") ? "group" : "dm", muted: !!(c as { notificationDisabled?: boolean }).notificationDisabled, }; @@ -1390,6 +1958,7 @@ async function resolveContactInfo(mid: string): Promise<{ return cached ?? { name: mid, picturePath: null, + statusMessage: "", kind: mid.startsWith("c") ? "group" : "dm", muted: false, }; @@ -1397,7 +1966,9 @@ async function resolveContactInfo(mid: string): Promise<{ } async function upsertChatFromMessage(message: Json) { - const peer = message.mine ? String(message.to) : String(message.from); + const peer = String( + message.chatMid ?? (message.mine ? message.to : message.from), + ); if (!peer) return; const preview = previewLine(message); const activity = Number(message.createdTime ?? 0); @@ -1619,6 +2190,11 @@ function sentMessagePayload( from: myMid(), to: chatMid, mine: true, + chatMid, + senderName: client?.base.profile?.displayName ?? "", + senderAvatarPath: null, + senderStatusMessage: client?.base.profile?.statusMessage ?? "", + senderKind: "line", createdTime: Number(sent?.createdTime ?? Date.now()), contentType: normType(sent?.contentType ?? "NONE"), imagePath: null, @@ -1629,6 +2205,7 @@ function sentMessagePayload( flex: null, durationMs: null, needsMedia: false, + reactions: [], }; } @@ -1666,6 +2243,76 @@ async function handle(req: Json) { String(params.text ?? ""), ); break; + case "react_message": { + if (!client) { + fail(id, "not_logged_in"); + break; + } + const chatMid = String(params.chatMid ?? ""); + const messageId = String(params.messageId ?? ""); + const reaction = reactionKind(params.reaction); + if (!chatMid || !messageId || !reaction || reaction === "ALL") { + fail(id, "invalid_reaction"); + break; + } + if (squareChatIndex.has(chatMid) || chatMid.startsWith("m")) { + const react = client.base.square.reactToMessage as unknown as ( + options: { + request: { + reqSeq: number; + reactionType: string; + messageId: string; + squareChatMid: string; + }; + }, + ) => Promise; + await react.call(client.base.square, { + request: { + reqSeq: 0, + reactionType: reaction, + messageId, + squareChatMid: chatMid, + }, + }); + } else { + const react = client.base.talk.react as unknown as ( + options: { id: bigint; reaction: string }, + ) => Promise; + await react.call(client.base.talk, { + id: BigInt(messageId), + reaction, + }); + } + await cacheOwnReaction(chatMid, messageId, reaction); + ok(id, { reacted: true, chatMid, messageId, reaction }); + break; + } + case "unsend_message": { + if (!client) { + fail(id, "not_logged_in"); + break; + } + const chatMid = String(params.chatMid ?? ""); + const messageId = String(params.messageId ?? ""); + if (!chatMid || !messageId) { + fail(id, "missing_message_id"); + break; + } + if (squareChatIndex.has(chatMid) || chatMid.startsWith("m")) { + const unsend = client.base.square.unsendMessage as unknown as ( + options: { squareChatMid: string; messageId: string }, + ) => Promise; + await unsend.call(client.base.square, { + squareChatMid: chatMid, + messageId, + }); + } else { + await client.base.talk.unsendMessage({ messageId }); + } + await removeCachedMessage(chatMid, messageId); + ok(id, { unsent: true, chatMid, messageId }); + break; + } case "send_audio": await outgoingMedia.sendAudio( id, @@ -1717,7 +2364,11 @@ async function handle(req: Json) { : undefined, ); calls.setCallGains(params.micGain, params.spkGain); - await calls.doCallStart(id, String(params.mid ?? params.chatMid ?? "")); + await calls.doCallStart( + id, + String(params.mid ?? params.chatMid ?? ""), + params.videoCapable === true, + ); break; case "call_answer": calls.setCallAudioDevices( @@ -1739,6 +2390,14 @@ async function handle(req: Json) { ok(id, calls.updateCallAudio(params)); break; } + case "call_screen_start": { + calls.doCallScreenStart(id); + break; + } + case "call_screen_stop": { + calls.doCallScreenStop(id); + break; + } case "send_postback": { if (!client) { fail(id, "not_logged_in"); @@ -1772,7 +2431,7 @@ async function handle(req: Json) { e2ee: true, }); } - msgCache.delete(chatMid); + markMessageCacheStale(chatMid); ok(id, { sent: true, fallback: "message" }); } catch { fail(id, e instanceof Error ? e.message : String(e)); @@ -1857,6 +2516,7 @@ async function handle(req: Json) { contactIndex.set(String(mid), { name: contact.displayName || userid, picturePath: contact.picturePath ?? null, + statusMessage: "", kind: "dm", muted: false, }); @@ -1870,6 +2530,37 @@ async function handle(req: Json) { } break; } + case "profile_relation": { + try { + ok(id, await contactRelationPayload(String(params.mid ?? ""))); + } catch (e) { + fail(id, e instanceof Error ? e.message : String(e)); + } + break; + } + case "add_friend_mid": { + if (!client) { + fail(id, "not_logged_in"); + break; + } + const mid = String(params.mid ?? ""); + if (!mid.startsWith("u")) { + fail(id, "profile_not_line_user"); + break; + } + if (mid === myMid()) { + fail(id, "cannot_add_self"); + break; + } + try { + await client.base.relation.addFriendByMid({ mid }); + const relation = await contactRelationPayload(mid); + ok(id, { ...relation, isFriend: true, canAdd: false, canChat: true }); + } catch (e) { + fail(id, e instanceof Error ? e.message : String(e)); + } + break; + } case "mute_chat": { if (!client) { fail(id, "not_logged_in"); @@ -1894,6 +2585,7 @@ async function handle(req: Json) { contactIndex.set(mid, { name: mid, picturePath: null, + statusMessage: "", kind: "dm", muted, }); @@ -1920,6 +2612,8 @@ async function handle(req: Json) { listening = false; chatCache = null; msgCache.clear(); + squareChatIndex.clear(); + squareMemberIndex.clear(); ok(id, {}); break; default: diff --git a/protocol/src/message_view.ts b/protocol/src/message_view.ts index ec29674..8a3121e 100644 --- a/protocol/src/message_view.ts +++ b/protocol/src/message_view.ts @@ -135,7 +135,10 @@ export function createMessageView(runtime: MessageViewRuntime) { const id = String(m.id ?? ""); let imagePath: string | null = (m.imagePath as string) || null; let audioPath: string | null = (m.audioPath as string) || null; - let needsMedia = isMediaType(ct) && !imagePath && !audioPath; + const mediaRetryAfter = Number(m.mediaRetryAfter ?? 0); + let mediaFailed = mediaRetryAfter > Date.now(); + let needsMedia = isMediaType(ct) && !imagePath && !audioPath && + !mediaFailed; if (ct === "STICKER") { const stkId = String(m.stickerId || ""); @@ -147,10 +150,11 @@ export function createMessageView(runtime: MessageViewRuntime) { if (anim) { imagePath = anim; needsMedia = false; + mediaFailed = false; } else if (st) { // Show static now; hydrate may upgrade to APNG. imagePath = st; - needsMedia = true; + needsMedia = !mediaFailed; } } } else if (ct === "IMAGE" || ct === "VIDEO") { @@ -161,6 +165,7 @@ export function createMessageView(runtime: MessageViewRuntime) { if (cached) { imagePath = (await uiMediaPath(id, cached)) || cached; needsMedia = false; + mediaFailed = false; } } else if (ct === "AUDIO") { audioPath = null; @@ -171,14 +176,16 @@ export function createMessageView(runtime: MessageViewRuntime) { break; } } - if (audioPath) needsMedia = false; - else needsMedia = true; + if (audioPath) { + needsMedia = false; + mediaFailed = false; + } else needsMedia = !mediaFailed; } else if (ct === "FILE") { // Filename-only bubble; download is on demand / background, not a preview. needsMedia = false; } - return { ...m, imagePath, audioPath, needsMedia }; + return { ...m, imagePath, audioPath, needsMedia, mediaFailed }; })); } diff --git a/protocol/src/messages.ts b/protocol/src/messages.ts index 4a44747..5b20840 100644 --- a/protocol/src/messages.ts +++ b/protocol/src/messages.ts @@ -8,6 +8,57 @@ export function isMediaType(contentType: string): boolean { contentType === "FILE"; } +export type SticonResource = { + start: number; + end: number; + productId: string; + sticonId: string; + resourceType: string; +}; + +/** Resolve the conversation MID for a Talk message, including group/room traffic. */ +export function talkChatMid(message: { + from?: unknown; + to?: unknown; + mine?: unknown; +}): string { + const from = String(message.from ?? ""); + const to = String(message.to ?? ""); + if (to.startsWith("c") || to.startsWith("r")) return to; + return message.mine ? to : from; +} + +/** Parse LINE emoji (STICON) replacements carried by NONE text messages. */ +export function sticonResources( + metadata: Record | null | undefined, +): SticonResource[] { + const encoded = metadata?.REPLACE; + if (!encoded) return []; + try { + const parsed = JSON.parse(encoded) as { + sticon?: { resources?: Record[] }; + }; + const rows = parsed.sticon?.resources; + if (!Array.isArray(rows)) return []; + return rows.flatMap((row) => { + const productId = String(row.productId ?? "").trim(); + const sticonId = String(row.sticonId ?? "").trim(); + if (!productId || !sticonId) return []; + const start = Number(row.S ?? 0); + const end = Number(row.E ?? start); + return [{ + start: Number.isFinite(start) ? start : 0, + end: Number.isFinite(end) ? end : 0, + productId, + sticonId, + resourceType: String(row.resourceType ?? "STATIC"), + }]; + }); + } catch { + return []; + } +} + /** LINE sometimes omits contentMetadata; linejs otherwise crashes. */ export function normalizeRaw( raw: Record | null | undefined, diff --git a/protocol/src/stickers.ts b/protocol/src/stickers.ts index 7da60e5..5ee8036 100644 --- a/protocol/src/stickers.ts +++ b/protocol/src/stickers.ts @@ -24,6 +24,7 @@ export type StickerRuntime = { invalidateMessages: (chatMid: string) => void; invalidateChats: () => void; touchChatPreviewFromMessage: (message: Json) => void; + emitEvent: (event: string, payload?: Json) => void; ok: (id: number | string | null, result?: unknown) => void; fail: (id: number | string | null, error: string) => void; }; @@ -40,6 +41,7 @@ export function createStickerService(runtime: StickerRuntime) { invalidateMessages, invalidateChats, touchChatPreviewFromMessage, + emitEvent, ok, fail, } = runtime; @@ -61,6 +63,28 @@ export function createStickerService(runtime: StickerRuntime) { ]; } + function sticonUrl(productId: string, sticonId: string): string { + return `https://stickershop.line-scdn.net/sticonshop/v1/sticon/${ + encodeURIComponent(productId) + }/android/${encodeURIComponent(sticonId)}.png`; + } + + async function ensureSticonImage( + productId: string, + sticonId: string, + ): Promise { + if (!productId || !sticonId) return null; + const safeProduct = productId.replace(/[^a-zA-Z0-9._-]/g, "_"); + const safeSticon = sticonId.replace(/[^a-zA-Z0-9._-]/g, "_"); + const dest = join( + stickerDir, + `sticon-${safeProduct}-${safeSticon}.png`, + ); + const existing = await existingImage(dest); + if (existing) return existing; + return await cacheUrl(sticonUrl(productId, sticonId), dest); + } + type StickerEntry = { stickerId: string; packageId: string; @@ -68,8 +92,23 @@ export function createStickerService(runtime: StickerRuntime) { at: number; }; + type StickerPackCatalog = { + id: string; + name: string; + version: string; + stickers: StickerEntry[]; + }; + + type StickerCatalog = { + at: number; + packs: StickerPackCatalog[]; + }; + const stickerIndexPath = join(dataDir, "stickers-index.json"); + const stickerCatalogPath = join(dataDir, "stickers-catalog.json"); let stickerIndex: StickerEntry[] = []; + let stickerCatalog: StickerCatalog | null = null; + let catalogRefresh: Promise | null = null; async function loadStickerIndex() { try { @@ -87,6 +126,39 @@ export function createStickerService(runtime: StickerRuntime) { } catch { stickerIndex = []; } + try { + const raw = JSON.parse(await Deno.readTextFile(stickerCatalogPath)); + if (Array.isArray(raw?.packs)) { + stickerCatalog = { + at: Number(raw.at ?? 0), + packs: raw.packs.filter((pack: StickerPackCatalog) => + pack?.id && Array.isArray(pack.stickers) + ), + }; + } + } catch { + stickerCatalog = null; + } + } + + async function mapPool( + items: T[], + concurrency: number, + worker: (item: T, index: number) => Promise, + ): Promise { + const out = new Array(items.length); + let cursor = 0; + const runners = Array.from( + { length: Math.min(concurrency, items.length) }, + async () => { + while (cursor < items.length) { + const index = cursor++; + out[index] = await worker(items[index]!, index); + } + }, + ); + await Promise.all(runners); + return out; } async function saveStickerIndex() { @@ -173,7 +245,6 @@ export function createStickerService(runtime: StickerRuntime) { async function stickersForOwnedPackage( pkg: OwnedPackage, - limit = 40, ): Promise<{ name: string; version: string; @@ -208,7 +279,6 @@ export function createStickerService(runtime: StickerRuntime) { } const version = String(meta.version ?? pkg.version ?? "1"); const stickers = (Array.isArray(meta.stickers) ? meta.stickers : []) - .slice(0, limit) .map((s) => ({ stickerId: String(s.id ?? ""), packageId: pkg.id, @@ -231,8 +301,16 @@ export function createStickerService(runtime: StickerRuntime) { ]; } + function packIconPath(packageId: string): string { + return join(stickerDir, `pack-${packageId}.png`); + } + + function stickerStaticPath(stickerId: string): string { + return join(stickerDir, `${stickerId}.png`); + } + async function ensurePackIcon(packageId: string): Promise { - const dest = join(stickerDir, `pack-${packageId}.png`); + const dest = packIconPath(packageId); const existing = await existingImage(dest); if (existing) return existing; for (const url of packIconUrls(packageId)) { @@ -276,7 +354,7 @@ export function createStickerService(runtime: StickerRuntime) { async function ensureStickerStatic( stickerId: string, ): Promise { - const dest = join(stickerDir, `${stickerId}.png`); + const dest = stickerStaticPath(stickerId); const existing = await existingImage(dest); if (existing) return existing; for (const url of stickerUrls(stickerId)) { @@ -293,95 +371,179 @@ export function createStickerService(runtime: StickerRuntime) { return await ensureStickerStatic(stickerId); } - async function doListStickers(id: number | string | null) { - const client = runtime.getClient(); - if (!client) { - fail(id, "not_logged_in"); - return; + async function saveStickerCatalog() { + if (!stickerCatalog) return; + try { + await atomicWriteTextFile( + stickerCatalogPath, + JSON.stringify(stickerCatalog), + ); + } catch (error) { + console.error("[sticker catalog]", error); } + } - const ownedPkgs = await fetchOwnedPackages(); - const ownedPkgIds = new Set(ownedPkgs.map((p) => p.id)); - const versionByPkg = new Map(ownedPkgs.map((p) => [p.id, p.version])); + async function refreshStickerCatalog(force = false): Promise { + await refreshCachePolicy(); + if ( + !force && stickerCatalog && + Date.now() - stickerCatalog.at < cachePolicy().ownedPack + ) return false; + if (catalogRefresh) { + await catalogRefresh; + return true; + } + catalogRefresh = (async () => { + const owned = await fetchOwnedPackages(); + const details = await mapPool( + owned, + 8, + (pkg) => stickersForOwnedPackage(pkg), + ); + stickerCatalog = { + at: Date.now(), + packs: details.map((detail, index) => ({ + id: owned[index]!.id, + name: detail.name || owned[index]!.name || owned[index]!.id, + version: detail.version, + stickers: detail.stickers, + })).filter((pack) => pack.stickers.length > 0), + }; + await saveStickerCatalog(); + })().finally(() => { + catalogRefresh = null; + }); + await catalogRefresh; + return true; + } - // Recents tab (owned only). - const recentEntries: StickerEntry[] = []; + async function stickerCatalogPayload(): Promise { + const catalog = stickerCatalog ?? { at: 0, packs: [] }; + const ownedIds = new Set(catalog.packs.map((pack) => pack.id)); + const versionByPack = new Map( + catalog.packs.map((pack) => [pack.id, pack.version]), + ); + const recent: StickerEntry[] = []; const recentSeen = new Set(); - for (const s of stickerIndex) { - if (!ownedPkgIds.has(s.packageId)) continue; - const key = `${s.packageId}:${s.stickerId}`; + for (const sticker of stickerIndex) { + if (!ownedIds.has(sticker.packageId)) continue; + const key = `${sticker.packageId}:${sticker.stickerId}`; if (recentSeen.has(key)) continue; recentSeen.add(key); - recentEntries.push({ - ...s, - version: s.version || versionByPkg.get(s.packageId), + recent.push({ + ...sticker, + version: sticker.version || versionByPack.get(sticker.packageId), }); - if (recentEntries.length >= 24) break; + if (recent.length >= 24) break; } - const packs: Json[] = []; + const packs = await mapPool(catalog.packs, 12, async (pack) => { + const stickers = await mapPool(pack.stickers, 32, async (sticker) => ({ + stickerId: sticker.stickerId, + packageId: sticker.packageId, + version: sticker.version ?? pack.version, + imagePath: await existingImage(stickerStaticPath(sticker.stickerId)), + })); + const iconPath = await existingImage(packIconPath(pack.id)) ?? + stickers.find((sticker) => sticker.imagePath)?.imagePath ?? null; + return { + id: pack.id, + name: pack.name, + version: pack.version, + iconPath, + recent: false, + stickers, + }; + }); - if (recentEntries.length) { - const stickers: Json[] = []; - for (const s of recentEntries) { - stickers.push({ - stickerId: s.stickerId, - packageId: s.packageId, - version: s.version ?? null, - imagePath: await ensureStickerStatic(s.stickerId), - }); - } - packs.push({ + if (recent.length) { + const stickers = await mapPool(recent, 24, async (sticker) => ({ + stickerId: sticker.stickerId, + packageId: sticker.packageId, + version: sticker.version ?? "", + imagePath: await existingImage(stickerStaticPath(sticker.stickerId)), + })); + packs.unshift({ id: "__recent__", name: "Recent", - version: null, + version: "", iconPath: stickers[0]?.imagePath ?? null, recent: true, stickers, }); } - // Owned packs with full grids + pack icons. - for (const pkg of ownedPkgs.slice(0, 12)) { - const detail = await stickersForOwnedPackage(pkg, 48); - if (!detail.stickers.length) continue; - const stickers: Json[] = []; - for (const s of detail.stickers) { - stickers.push({ - stickerId: s.stickerId, - packageId: s.packageId, - version: s.version ?? detail.version, - imagePath: await ensureStickerStatic(s.stickerId), - }); - } - let iconPath = await ensurePackIcon(pkg.id); - if (!iconPath) { - const first = stickers[0]?.imagePath; - iconPath = typeof first === "string" && first ? first : null; - } - packs.push({ - id: pkg.id, - name: detail.name || pkg.name || pkg.id, - version: detail.version, - iconPath, - recent: false, - stickers, - }); - } - - ok(id, { + return { packs, - // Keep flat list for older UI paths (unused once chooser migrates). - stickers: packs.flatMap((p) => - ((p.stickers as Json[]) ?? []).map((s) => ({ - ...s, - recent: p.id === "__recent__", + stickers: packs.flatMap((pack) => + pack.stickers.map((sticker) => ({ + ...sticker, + recent: pack.id === "__recent__", })) ), - ownedPackages: ownedPkgs.length, + ownedPackages: catalog.packs.length, + cached: true, + }; + } + + let thumbnailWarm: Promise | null = null; + function warmStickerThumbnails() { + if (!stickerCatalog || thumbnailWarm) return; + const catalog = stickerCatalog; + thumbnailWarm = (async () => { + const priorityIds = [ + ...stickerIndex.slice(0, 24).map((sticker) => sticker.stickerId), + ...(catalog.packs[0]?.stickers ?? []).map((sticker) => + sticker.stickerId + ), + ]; + const allIds = catalog.packs.flatMap((pack) => + pack.stickers.map((sticker) => sticker.stickerId) + ); + const seen = new Set(); + const priority = priorityIds.filter((id) => + id && !seen.has(id) && seen.add(id) + ); + const rest = allIds.filter((id) => id && !seen.has(id) && seen.add(id)); + + await Promise.all([ + mapPool(catalog.packs, 12, (pack) => ensurePackIcon(pack.id)), + mapPool(priority, 24, (stickerId) => ensureStickerStatic(stickerId)), + ]); + emitEvent("stickers_updated", await stickerCatalogPayload()); + await mapPool(rest, 24, (stickerId) => ensureStickerStatic(stickerId)); + emitEvent("stickers_updated", await stickerCatalogPayload()); + })().catch((error) => { + console.error("[sticker thumbnail warm]", error); + }).finally(() => { + thumbnailWarm = null; }); } + async function doListStickers(id: number | string | null) { + const client = runtime.getClient(); + if (!client) { + fail(id, "not_logged_in"); + return; + } + try { + if (!stickerCatalog?.packs.length) { + await refreshStickerCatalog(true); + } + ok(id, await stickerCatalogPayload()); + warmStickerThumbnails(); + if (stickerCatalog) { + void refreshStickerCatalog(false).then(async (updated) => { + if (!updated) return; + emitEvent("stickers_updated", await stickerCatalogPayload()); + warmStickerThumbnails(); + }); + } + } catch (error) { + fail(id, error instanceof Error ? error.message : String(error)); + } + } + async function doSendSticker( id: number | string | null, chatMid: string, @@ -474,6 +636,8 @@ export function createStickerService(runtime: StickerRuntime) { ensureStatic: ensureStickerStatic, ensureImage: ensureStickerImage, animationUrl: (stickerId: string) => stickerAnimationUrls(stickerId)[0], + ensureSticon: ensureSticonImage, + sticonUrl, list: doListStickers, send: doSendSticker, resetOwnedCache: () => { diff --git a/protocol/tests/h264_test.ts b/protocol/tests/h264_test.ts new file mode 100644 index 0000000..05de6fa --- /dev/null +++ b/protocol/tests/h264_test.ts @@ -0,0 +1,40 @@ +import { assertEquals } from "@std/assert"; +import { + AnnexBParser, + H264AccessUnitAssembler, + packetizeH264AccessUnit, + packetizeH264Nal, +} from "../src/h264.ts"; + +Deno.test("AnnexBParser handles start codes split across chunks", () => { + const parser = new AnnexBParser(); + assertEquals(parser.push(new Uint8Array([0, 0, 0])), []); + assertEquals(parser.push(new Uint8Array([1, 0x67, 1, 2, 0, 0])), []); + assertEquals(parser.push(new Uint8Array([1, 0x68, 3])), [ + new Uint8Array([0x67, 1, 2]), + ]); + assertEquals(parser.flush(), [new Uint8Array([0x68, 3])]); +}); + +Deno.test("packetizeH264Nal emits RFC 6184 FU-A fragments", () => { + const nal = new Uint8Array([0x65, 1, 2, 3, 4, 5, 6]); + const packets = packetizeH264Nal(nal, 5); + assertEquals(packets, [ + new Uint8Array([0x7c, 0x85, 1, 2, 3]), + new Uint8Array([0x7c, 0x45, 4, 5, 6]), + ]); +}); + +Deno.test("access units mark only their final RTP payload", () => { + const assembler = new H264AccessUnitAssembler(); + assertEquals(assembler.push(new Uint8Array([0x09, 0x10])), []); + assertEquals(assembler.push(new Uint8Array([0x67, 1])), []); + assertEquals(assembler.push(new Uint8Array([0x65, 2])), []); + const units = assembler.push(new Uint8Array([0x09, 0x10])); + assertEquals(units.length, 1); + assertEquals(packetizeH264AccessUnit(units[0]!, 1100), [ + { payload: new Uint8Array([0x09, 0x10]), endOfFrame: false }, + { payload: new Uint8Array([0x67, 1]), endOfFrame: false }, + { payload: new Uint8Array([0x65, 2]), endOfFrame: true }, + ]); +}); diff --git a/protocol/tests/helpers_test.ts b/protocol/tests/helpers_test.ts index 9331f22..5e18aa2 100644 --- a/protocol/tests/helpers_test.ts +++ b/protocol/tests/helpers_test.ts @@ -1,6 +1,12 @@ import { assertEquals } from "@std/assert"; -import { picturePathOf, profileUrl } from "../src/contacts.ts"; -import { coerceI64, isMediaType, normalizeRaw } from "../src/messages.ts"; +import { picturePathOf, profileUrl, squareObsUrl } from "../src/contacts.ts"; +import { + coerceI64, + isMediaType, + normalizeRaw, + sticonResources, + talkChatMid, +} from "../src/messages.ts"; Deno.test("message normalization supplies metadata", () => { assertEquals(normalizeRaw({ text: "hello" }), { @@ -24,3 +30,56 @@ Deno.test("contact picture helpers normalize LINE paths", () => { "https://example.test/photo", ); }); + +Deno.test("OpenChat picture helper uses the OBS object directly", () => { + assertEquals(squareObsUrl("0habc"), "https://obs.line-scdn.net/0habc"); + assertEquals( + squareObsUrl("https://example.test/square.jpg"), + "https://example.test/square.jpg", + ); +}); + +Deno.test("LINE emoji replacement metadata exposes its STICON CDN identity", () => { + assertEquals( + sticonResources({ + REPLACE: JSON.stringify({ + sticon: { + resources: [{ + S: 0, + E: 14, + productId: "5ac1bfd5040ab15980c9b435", + sticonId: "001", + resourceType: "STATIC", + }], + }, + }), + }), + [{ + start: 0, + end: 14, + productId: "5ac1bfd5040ab15980c9b435", + sticonId: "001", + resourceType: "STATIC", + }], + ); + assertEquals(sticonResources({ REPLACE: "not-json" }), []); +}); + +Deno.test("Talk messages route incoming group traffic to the group MID", () => { + assertEquals( + talkChatMid({ from: "u-sender", to: "c-group", mine: false }), + "c-group", + ); + assertEquals( + talkChatMid({ from: "u-sender", to: "u-me", mine: false }), + "u-sender", + ); + assertEquals( + talkChatMid({ from: "u-me", to: "u-peer", mine: true }), + "u-peer", + ); + assertEquals( + talkChatMid({ from: "u-sender", to: "r-room", mine: false }), + "r-room", + ); +}); diff --git a/protocol/tests/planet_video_test.ts b/protocol/tests/planet_video_test.ts new file mode 100644 index 0000000..be9a4d8 --- /dev/null +++ b/protocol/tests/planet_video_test.ts @@ -0,0 +1,27 @@ +import { assertEquals } from "@std/assert"; +import { + decodeNativeSetupOffer, + packNativeSetupOffer, +} from "../vendor/linejs-call/planet/schema.ts"; + +Deno.test("native PLANET offer negotiates the H.264 video RTP stream", () => { + const offer = packNativeSetupOffer({ + mediaPubKey: new Uint8Array(33).fill(1), + mediaKeyId: 42, + mediaNonce: new Uint8Array(16).fill(2), + mediaSecret: new Uint8Array(30).fill(3), + }, { + videoEnabled: true, + videoBitrateKbps: 800, + videoFps: 15, + }); + const decoded = decodeNativeSetupOffer(offer); + const video = decoded.media.find((media) => media.name === "V"); + + assertEquals(video?.enabled, 1); + assertEquals(video?.bitrate, 800); + assertEquals(video?.kind, 2); + assertEquals(video?.rtpId, 97); + assertEquals(video?.rtpPort, 111); + assertEquals(video?.rtcpId, 211); +}); diff --git a/protocol/vendor/linejs-call/planet/schema.ts b/protocol/vendor/linejs-call/planet/schema.ts index 7511f0c..5e54a67 100644 --- a/protocol/vendor/linejs-call/planet/schema.ts +++ b/protocol/vendor/linejs-call/planet/schema.ts @@ -242,6 +242,13 @@ export interface PlanetSetupOfferMaterial { mediaSecret: Uint8Array; } +export interface PlanetSetupOfferOptions { + /** Advertise the native VIDEO RTP path instead of the audio-only default. */ + videoEnabled?: boolean; + videoBitrateKbps?: number; + videoFps?: number; +} + function packPortPair(primary: number, secondary: number): Uint8Array { const b: Buf = { bytes: [] }; emitUint32(b, 1, primary); @@ -332,6 +339,7 @@ function packDataOffer(codec: Uint8Array, path: Uint8Array): Uint8Array { */ export function packNativeSetupOffer( material: PlanetSetupOfferMaterial, + opts: PlanetSetupOfferOptions = {}, ): Uint8Array { if (material.mediaPubKey.length !== 33) { throw new Error("packNativeSetupOffer: mediaPubKey must be 33 bytes"); @@ -355,9 +363,9 @@ export function packNativeSetupOffer( const video = packAudioVideoOffer( "V", packOfferCodec("V", { - enabled: 0, - bitrate: 800, - fps: 24, + enabled: opts.videoEnabled ? 1 : 0, + bitrate: Math.max(100, Math.round(opts.videoBitrateKbps ?? 800)), + fps: Math.max(1, Math.round(opts.videoFps ?? 24)), profile: 2, kind: 2, }), diff --git a/protocol/vendor/linejs-call/planet/transport.ts b/protocol/vendor/linejs-call/planet/transport.ts index 9e0c621..14b14c7 100644 --- a/protocol/vendor/linejs-call/planet/transport.ts +++ b/protocol/vendor/linejs-call/planet/transport.ts @@ -115,6 +115,10 @@ export interface PlanetTransportOpts { keepaliveIntervalMs?: number; mediaKeyMode?: PlanetMediaKeyMode; rtpTimestampStep?: number; + /** Advertise and configure the native PT97 VIDEO path for screen sharing. */ + enableVideo?: boolean; + videoBitrateKbps?: number; + videoFps?: number; preferIpv6?: boolean; groupDataSessionAfterProvisional?: boolean; wireSend?: ( @@ -221,6 +225,8 @@ interface MediaKeyCandidate { recv: string; sendContext: SrtpCryptoContext; recvContext: SrtpCryptoContext; + videoSendContext?: SrtpCryptoContext; + videoRecvContext?: SrtpCryptoContext; } const MEDIA_KEY_SELECTIONS: Record< @@ -863,7 +869,11 @@ function fieldText(fields: DecodedField[], tag: number): string | undefined { return /^[\x20-\x7e]{0,80}$/.test(s) ? s : undefined; } -function defaultLocalMediaOffer(): PlanetLocalMediaOffer { +function defaultLocalMediaOffer(opts: { + videoEnabled?: boolean; + videoBitrateKbps?: number; + videoFps?: number; +} = {}): PlanetLocalMediaOffer { const media = generateEphemeralKeypair(); const material: PlanetSetupOfferMaterial = { mediaPubKey: media.publicKey, @@ -877,7 +887,7 @@ function defaultLocalMediaOffer(): PlanetLocalMediaOffer { privateKey: copyBytes(media.privateKey), }, material: cloneMediaOfferMaterial(material), - offer: packNativeSetupOffer(material), + offer: packNativeSetupOffer(material, opts), }; } @@ -979,6 +989,8 @@ export class PlanetTransport implements CallTransport { #localMediaChanId = 1n; #srtpSend?: SrtpCryptoContext; #srtpRecv?: SrtpCryptoContext; + #videoSrtpSend?: SrtpCryptoContext; + #videoSrtpRecv?: SrtpCryptoContext; #groupDataSrtpSend?: SrtpCryptoContext; #dataSrtpRecv?: SrtpCryptoContext; /** Extra DATA-channel recv contexts (line-gtk patch for PT98). */ @@ -994,6 +1006,14 @@ export class PlanetTransport implements CallTransport { seq: number; timestamp: number; }; + #videoRtp?: { + host: string; + port: number; + payloadType: number; + ssrc: number; + seq: number; + timestamp: number; + }; #groupDataRtp?: { ssrc: number; seq: number; @@ -1105,6 +1125,8 @@ export class PlanetTransport implements CallTransport { this.#peerMediaOffer = undefined; this.#srtpSend = undefined; this.#srtpRecv = undefined; + this.#videoSrtpSend = undefined; + this.#videoSrtpRecv = undefined; this.#groupDataSrtpSend = undefined; this.#dataSrtpRecv = undefined; this.#dataSrtpRecvCandidates = []; @@ -1112,6 +1134,7 @@ export class PlanetTransport implements CallTransport { this.#mediaKeyMode = undefined; this.#mediaKeyCandidates = []; this.#rtp = undefined; + this.#videoRtp = undefined; this.#groupDataRtp = undefined; this.#rtpQueue = []; this.#queued = []; @@ -1683,7 +1706,11 @@ export class PlanetTransport implements CallTransport { const cid = this.#callUuid!; const localMediaOffer = this.#opts.setupOffer ? undefined - : defaultLocalMediaOffer(); + : defaultLocalMediaOffer({ + videoEnabled: this.#opts.enableVideo, + videoBitrateKbps: this.#opts.videoBitrateKbps, + videoFps: this.#opts.videoFps, + }); if (localMediaOffer) this.#localMediaOffer = localMediaOffer; const setup: CcSetupReq = { initiator: this.#opts.localMid, @@ -1707,7 +1734,8 @@ export class PlanetTransport implements CallTransport { cid, ), fakeCall: false, - svcKey: this.#opts.serviceKey ?? "freecall.audio", + svcKey: this.#opts.serviceKey ?? + (this.#opts.enableVideo ? "freecall.video" : "freecall.audio"), netType: 1, stid: this.#route.stid, features: this.#opts.features ?? defaultSetupFeatures(), @@ -2118,6 +2146,12 @@ export class PlanetTransport implements CallTransport { ...selection, sendContext: await deriveSrtpContext(sendKeying), recvContext: await deriveSrtpContext(recvKeying), + videoSendContext: await deriveSrtpContext( + derivePlanetMediaStreamKeying(sendKeying, "VIDEO"), + ), + videoRecvContext: await deriveSrtpContext( + derivePlanetMediaStreamKeying(recvKeying, "VIDEO"), + ), }, { mode: audioMediaKeyMode(selection.mode), send: `AUDIO/${selection.send}`, @@ -2128,6 +2162,12 @@ export class PlanetTransport implements CallTransport { recvContext: await deriveSrtpContext( derivePlanetMediaStreamKeying(recvKeying, "AUDIO"), ), + videoSendContext: await deriveSrtpContext( + derivePlanetMediaStreamKeying(sendKeying, "VIDEO"), + ), + videoRecvContext: await deriveSrtpContext( + derivePlanetMediaStreamKeying(recvKeying, "VIDEO"), + ), }); // line-gtk: keep DATA stream contexts for PT98 (1:1 data channel). this.#dataSrtpRecvCandidates.push( @@ -2151,6 +2191,12 @@ export class PlanetTransport implements CallTransport { recv: "local-secret", sendContext: await deriveSrtpContext(peerOffer.mediaSecret), recvContext: await deriveSrtpContext(local.material.mediaSecret), + videoSendContext: await deriveSrtpContext( + derivePlanetMediaStreamKeying(peerOffer.mediaSecret, "VIDEO"), + ), + videoRecvContext: await deriveSrtpContext( + derivePlanetMediaStreamKeying(local.material.mediaSecret, "VIDEO"), + ), }, { mode: "secret-sender", @@ -2158,6 +2204,12 @@ export class PlanetTransport implements CallTransport { recv: "peer-secret", sendContext: await deriveSrtpContext(local.material.mediaSecret), recvContext: await deriveSrtpContext(peerOffer.mediaSecret), + videoSendContext: await deriveSrtpContext( + derivePlanetMediaStreamKeying(local.material.mediaSecret, "VIDEO"), + ), + videoRecvContext: await deriveSrtpContext( + derivePlanetMediaStreamKeying(peerOffer.mediaSecret, "VIDEO"), + ), }, { mode: "audio-secret-receiver", @@ -2169,6 +2221,12 @@ export class PlanetTransport implements CallTransport { recvContext: await deriveSrtpContext( derivePlanetMediaStreamKeying(local.material.mediaSecret, "AUDIO"), ), + videoSendContext: await deriveSrtpContext( + derivePlanetMediaStreamKeying(peerOffer.mediaSecret, "VIDEO"), + ), + videoRecvContext: await deriveSrtpContext( + derivePlanetMediaStreamKeying(local.material.mediaSecret, "VIDEO"), + ), }, { mode: "audio-secret-sender", @@ -2180,6 +2238,12 @@ export class PlanetTransport implements CallTransport { recvContext: await deriveSrtpContext( derivePlanetMediaStreamKeying(peerOffer.mediaSecret, "AUDIO"), ), + videoSendContext: await deriveSrtpContext( + derivePlanetMediaStreamKeying(local.material.mediaSecret, "VIDEO"), + ), + videoRecvContext: await deriveSrtpContext( + derivePlanetMediaStreamKeying(peerOffer.mediaSecret, "VIDEO"), + ), }, ); this.#dataSrtpRecvCandidates.push( @@ -2201,6 +2265,13 @@ export class PlanetTransport implements CallTransport { if (!initial) return false; this.#srtpSend = initial.sendContext; this.#srtpRecv = initial.recvContext; + this.#videoSrtpSend = this.#opts.enableVideo + ? initial.videoSendContext + : undefined; + this.#videoSrtpRecv = this.#opts.enableVideo + ? initial.videoRecvContext + : undefined; + this.#videoRtp = undefined; this.#groupDataSrtpSend = undefined; this.#groupDataRtp = undefined; this.#dataSrtpRecv = undefined; @@ -2272,6 +2343,11 @@ export class PlanetTransport implements CallTransport { ) ?? peerOffer.media.find((m) => m.kind === 1 && m.enabled !== 0 && m.rtpId !== undefined ); + const video = peerOffer.media.find((m) => + m.name === "V" && m.enabled !== 0 && m.rtpId !== undefined + ) ?? peerOffer.media.find((m) => + m.kind === 2 && m.enabled !== 0 && m.rtpId !== undefined + ); const data = peerOffer.media.find((m) => m.name === "D" && m.enabled !== 0 && m.rtpId !== undefined @@ -2287,6 +2363,7 @@ export class PlanetTransport implements CallTransport { // 1:1: send with OUR SETUP-offer SSRCs (101/121), never the peer's // rtpPort/rtcpId. Using the peer's ids makes CONN checks fail → 511. const localAudioSsrc = LOCAL_1TO1_AUDIO_RTP_SSRC; + const localVideoSsrc = LOCAL_1TO1_VIDEO_RTP_SSRC; const localDataSsrc = LOCAL_1TO1_DATA_RTP_SSRC; this.#peerMediaOffer = peerOffer; this.#rtp = { @@ -2298,6 +2375,18 @@ export class PlanetTransport implements CallTransport { seq: randomIntInclusive(0, 0xffff), timestamp: 0, }; + if ( + this.#opts.enableVideo && this.#videoSrtpSend && video?.rtpId !== undefined + ) { + this.#videoRtp = { + host: endpoint.host, + port: endpoint.port, + payloadType: video.rtpId, + ssrc: localVideoSsrc, + seq: randomIntInclusive(0, 0xffff), + timestamp: randomIntInclusive(0, 0x7fffffff), + }; + } if (this.#groupDataSrtpSend) { this.#groupDataRtp = { ssrc: this.#groupDataSsrc ?? localDataSsrc, @@ -2317,8 +2406,12 @@ export class PlanetTransport implements CallTransport { rtcpId: audio?.rtcpId, rtpPort: audio?.rtpPort, localAudioSsrc, + localVideoSsrc, localDataSsrc, peerAudioRtpPort: audio?.rtpPort, + peerVideoRtpPort: video?.rtpPort, + videoEnabled: !!this.#videoRtp, + videoPayloadType: this.#videoRtp?.payloadType, groupDataSsrc: this.#groupDataRtp?.ssrc, dataPayloadType: this.#dataRtpPayloadType, dataRecvContexts: this.#dataSrtpRecvCandidates.length + @@ -2732,6 +2825,75 @@ export class PlanetTransport implements CallTransport { await this.#sendGroupRtcpFeedback(); } + videoReady(): boolean { + return !!this.#videoSrtpSend && !!this.#videoRtp; + } + + /** Send one already-packetized native video RTP payload. + * + * The caller is responsible for codec framing (for H.264, RFC 6184 NAL or + * FU-A payloads). All fragments in one frame share a timestamp; set + * `endOfFrame` only on the final fragment so the RTP marker and 90 kHz clock + * advance together. + */ + async sendVideo( + videoPayload: Uint8Array, + opts: { endOfFrame?: boolean; timestampStep?: number } = {}, + ): Promise { + if (!this.#videoSrtpSend || !this.#videoRtp) { + throw new Error("PlanetTransport.sendVideo: video media not established"); + } + const video = this.#videoRtp; + const seq = video.seq++ & 0xffff; + const timestamp = video.timestamp >>> 0; + const endOfFrame = !!opts.endOfFrame; + const rtp = buildRtp({ + payloadType: video.payloadType, + marker: endOfFrame, + seq, + timestamp, + ssrc: video.ssrc, + payload: videoPayload, + }); + const wire = await srtpEncrypt(this.#videoSrtpSend, rtp); + this.#debug({ + type: "video_send", + bytes: wire.length, + payloadBytes: videoPayload.length, + payloadType: video.payloadType, + marker: endOfFrame, + ssrc: video.ssrc, + seq, + timestamp, + }); + if (this.#opts.wireSend) { + await this.#opts.wireSend(wire, { + host: video.host, + port: video.port, + bootstrap: false, + seq: video.seq, + plainLen: videoPayload.length, + bodyLen: wire.length, + plaintext: videoPayload, + }); + } else { + if (!this.#sock) { + throw new Error("PlanetTransport.sendVideo: socket closed"); + } + await new Promise((res, rj) => + this.#sock!.send( + Buffer.from(wire), + video.port, + video.host, + (e) => (e ? rj(e) : res()), + ) + ); + } + if (endOfFrame) { + video.timestamp = (video.timestamp + (opts.timestampStep ?? 3750)) >>> 0; + } + } + #nextAudioRtpTimestamp(timestampStep: number): number { if ( this.#groupJoined && this.#rtp?.timestamp === 0 && timestampStep === 1920 diff --git a/src/config.rs b/src/config.rs index 42098b6..49d7bf1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,29 @@ use gtk::CssProvider; use serde::{Deserialize, Serialize}; use std::cell::RefCell; +use std::collections::BTreeMap; use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct ChatBackgroundConfig { + /// default | mint | ocean | dusk | graphite | custom + pub preset: String, + /// App-private cached image used only when preset == custom. + pub image_path: String, +} + +impl Default for ChatBackgroundConfig { + fn default() -> Self { + Self { + preset: "default".into(), + image_path: String::new(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct AppConfig { @@ -25,6 +44,10 @@ pub struct AppConfig { pub auto_mark_read: bool, /// Last opened chat mid (restored on launch). pub last_chat_mid: String, + /// Chats pinned to the top in this native client (also covers OpenChat). + pub pinned_chats: Vec, + /// Per-room wallpaper preferences, including OpenChat rooms. + pub chat_backgrounds: BTreeMap, /// PulseAudio / PipeWire source name, or empty / "default". pub audio_input: String, /// PulseAudio / PipeWire sink name, or empty / "default". @@ -35,7 +58,7 @@ pub struct AppConfig { pub call_spk_volume: f64, /// Chat list sidebar width in px (remembered across launches). pub sidebar_width: i32, - /// Unlock experimental voice calls (unstable; off by default). + /// Unlock voice calls. The LINE transport remains unofficial/experimental. pub experimental_calls: bool, /// Keep a StatusNotifierItem tray icon while running. pub tray_enabled: bool, @@ -76,12 +99,14 @@ impl Default for AppConfig { notification_sound_volume: 1.0, auto_mark_read: true, last_chat_mid: String::new(), + pinned_chats: Vec::new(), + chat_backgrounds: BTreeMap::new(), audio_input: String::new(), audio_output: String::new(), call_mic_volume: 1.0, call_spk_volume: 1.0, sidebar_width: 320, - experimental_calls: false, + experimental_calls: true, tray_enabled: true, close_to_tray: true, discord_rpc: false, @@ -170,6 +195,22 @@ impl AppConfig { self.call_spk_volume = finite_or(self.call_spk_volume, 1.0).clamp(0.0, 2.5); self.sidebar_width = self.sidebar_width.clamp(80, 520); self.notifications_muted_until = self.notifications_muted_until.max(0); + self.pinned_chats.retain(|mid| !mid.trim().is_empty()); + self.pinned_chats.sort(); + self.pinned_chats.dedup(); + self.chat_backgrounds + .retain(|mid, _| !mid.trim().is_empty()); + for background in self.chat_backgrounds.values_mut() { + if !matches!( + background.preset.as_str(), + "default" | "mint" | "ocean" | "dusk" | "graphite" | "custom" + ) { + background.preset = "default".into(); + } + if background.preset != "custom" { + background.image_path.clear(); + } + } } /// Discord Application ID from settings, env, or the built-in default. @@ -268,6 +309,20 @@ mod tests { notifications_muted_until: -1, ..AppConfig::default() }; + cfg.chat_backgrounds.insert( + "room-invalid".into(), + ChatBackgroundConfig { + preset: "neon".into(), + image_path: "/tmp/should-be-cleared.png".into(), + }, + ); + cfg.chat_backgrounds.insert( + "room-custom".into(), + ChatBackgroundConfig { + preset: "custom".into(), + image_path: "/tmp/wallpaper.png".into(), + }, + ); cfg.normalize(); assert_eq!(cfg.theme, "system"); assert_eq!(cfg.language, "en"); @@ -277,6 +332,13 @@ mod tests { assert_eq!(cfg.call_mic_volume, 0.0); assert_eq!(cfg.sidebar_width, 520); assert_eq!(cfg.notifications_muted_until, 0); + let invalid = &cfg.chat_backgrounds["room-invalid"]; + assert_eq!(invalid.preset, "default"); + assert!(invalid.image_path.is_empty()); + assert_eq!( + cfg.chat_backgrounds["room-custom"].image_path, + "/tmp/wallpaper.png" + ); } #[test] diff --git a/src/main.rs b/src/main.rs index 7c574a5..9100b7e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod ui; use anyhow::{Context, Result}; use std::env; use std::path::PathBuf; +use tracing_subscriber::fmt::writer::MakeWriterExt; fn main() -> Result<()> { let data_dir = dirs::data_dir() @@ -24,7 +25,7 @@ fn main() -> Result<()> { tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) - .with_writer(log_writer) + .with_writer(std::io::stderr.and(log_writer)) .with_ansi(false) .init(); diff --git a/src/protocol.rs b/src/protocol.rs index 7264f20..bd5c200 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -51,6 +51,8 @@ pub struct ChatInfo { pub preview: String, #[serde(rename = "muted", default)] pub muted: bool, + #[serde(default)] + pub pinned: bool, } #[derive(Debug, Clone, Deserialize)] @@ -75,7 +77,17 @@ pub struct FlexInfo { pub actions: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] +pub struct MessageReactionInfo { + #[serde(default)] + pub kind: String, + #[serde(default)] + pub count: u32, + #[serde(default)] + pub mine: bool, +} + +#[derive(Debug, Clone, Default, Deserialize)] pub struct MessageInfo { #[serde(default)] pub id: String, @@ -87,6 +99,16 @@ pub struct MessageInfo { pub to: String, #[serde(default)] pub mine: bool, + #[serde(rename = "chatMid", default)] + pub chat_mid: String, + #[serde(rename = "senderName", default)] + pub sender_name: String, + #[serde(rename = "senderAvatarPath", default)] + pub sender_avatar_path: Option, + #[serde(rename = "senderStatusMessage", default)] + pub sender_status_message: String, + #[serde(rename = "senderKind", default)] + pub sender_kind: String, #[serde(rename = "createdTime", default)] pub created_time: i64, #[serde(rename = "contentType", default)] @@ -101,8 +123,12 @@ pub struct MessageInfo { pub file_path: Option, #[serde(rename = "durationMs", default)] pub duration_ms: Option, + #[serde(rename = "mediaFailed", default)] + pub media_failed: bool, #[serde(default)] pub flex: Option, + #[serde(default)] + pub reactions: Vec, } #[derive(Debug, Clone)] @@ -128,7 +154,7 @@ pub enum ProtocolEvent { pin: String, }, Listening, - Message(MessageInfo), + Message(Box), Chats { chats: Vec, cached: bool, @@ -137,6 +163,9 @@ pub enum ProtocolEvent { chat_mid: String, messages: Vec, }, + StickersUpdated { + result: Value, + }, AvatarReady { mid: String, avatar_path: String, @@ -198,6 +227,11 @@ pub enum ProtocolEvent { peer: String, state: String, error: Option, + video_capable: bool, + }, + ScreenShareState { + state: String, + error: Option, }, Response { id: u64, @@ -230,6 +264,7 @@ mod tests { assert_eq!(message.text, "hello"); assert_eq!(message.created_time, 0); assert!(!message.mine); + assert!(message.sender_name.is_empty()); assert!(message.image_path.is_none()); } diff --git a/src/sidecar.rs b/src/sidecar.rs index 2a67879..7cfd53d 100644 --- a/src/sidecar.rs +++ b/src/sidecar.rs @@ -80,7 +80,7 @@ impl Sidecar { .arg("--allow-read") .arg(format!("--allow-write={}", self.data_dir.display())) .arg("--allow-net") - .arg("--allow-run=ffmpeg,ffprobe") + .arg("--allow-run=ffmpeg,ffprobe,slurp,wf-recorder") .arg("--allow-sys") .arg("--allow-env=HOME,PATH,DENO_DIR,Q_DEBUG,NODE_DEBUG,NO_COLOR,LINE_GTK_DATA,LINE_GTK_LANG,LINE_GTK_CACHE_RETENTION,LINE_GTK_AUDIO_INPUT,LINE_GTK_AUDIO_OUTPUT,LINE_DEVICE,LINE_VERSION,LINE_CALL_DEVNAME,LINE_CALL_DEVICE_INFO,LINE_CALL_OPUS_SIGNAL,LINE_CALL_DEBUG") .arg(&script); @@ -263,6 +263,24 @@ impl Sidecar { ) } + pub fn react_message(&self, chat_mid: &str, message_id: &str, reaction: &str) -> Result { + self.request( + "react_message", + Some(json!({ + "chatMid": chat_mid, + "messageId": message_id, + "reaction": reaction, + })), + ) + } + + pub fn unsend_message(&self, chat_mid: &str, message_id: &str) -> Result { + self.request( + "unsend_message", + Some(json!({ "chatMid": chat_mid, "messageId": message_id })), + ) + } + pub fn send_audio( &self, chat_mid: &str, @@ -341,6 +359,7 @@ impl Sidecar { audio_output: &str, mic_gain: f64, spk_gain: f64, + video_capable: bool, ) -> Result { self.request( "call_start", @@ -350,6 +369,7 @@ impl Sidecar { "audioOutput": if audio_output.is_empty() { "default" } else { audio_output }, "micGain": mic_gain, "spkGain": spk_gain, + "videoCapable": video_capable, })), ) } @@ -380,6 +400,14 @@ impl Sidecar { self.request("call_end", None) } + pub fn call_screen_start(&self) -> Result { + self.request("call_screen_start", None) + } + + pub fn call_screen_stop(&self) -> Result { + self.request("call_screen_stop", None) + } + pub fn call_set_audio( &self, muted: bool, @@ -427,6 +455,14 @@ impl Sidecar { self.request("add_friend", Some(json!({ "userid": userid }))) } + pub fn profile_relation(&self, mid: &str) -> Result { + self.request("profile_relation", Some(json!({ "mid": mid }))) + } + + pub fn add_friend_mid(&self, mid: &str) -> Result { + self.request("add_friend_mid", Some(json!({ "mid": mid }))) + } + pub fn logout(&self) -> Result { self.request("logout", None) } @@ -545,7 +581,7 @@ fn read_loop( "message" => { if let Some(msg) = resp.extra.get("message") { match serde_json::from_value(msg.clone()) { - Ok(m) => ProtocolEvent::Message(m), + Ok(m) => ProtocolEvent::Message(Box::new(m)), Err(e) => ProtocolEvent::Error(e.to_string()), } } else { @@ -581,6 +617,9 @@ fn read_loop( .unwrap_or_default(); ProtocolEvent::Messages { chat_mid, messages } } + "stickers_updated" => ProtocolEvent::StickersUpdated { + result: resp.extra.clone(), + }, "avatar_ready" => ProtocolEvent::AvatarReady { mid: resp .extra @@ -716,6 +755,19 @@ fn read_loop( .and_then(|v| v.as_str()) .map(|s| s.to_string()), }, + "screen_share_state" => ProtocolEvent::ScreenShareState { + state: resp + .extra + .get("state") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + error: resp + .extra + .get("error") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }, "upload_progress" => ProtocolEvent::UploadProgress { chat_mid: resp .extra @@ -818,6 +870,11 @@ fn read_loop( .get("error") .and_then(|v| v.as_str()) .map(|s| s.to_string()), + video_capable: resp + .extra + .get("videoCapable") + .and_then(|v| v.as_bool()) + .unwrap_or(false), }, other => ProtocolEvent::Error(format!("unknown event: {other}")), }; diff --git a/src/sticker_anim.rs b/src/sticker_anim.rs index d36515a..56baeb0 100644 --- a/src/sticker_anim.rs +++ b/src/sticker_anim.rs @@ -95,6 +95,41 @@ pub fn load_scaled(path: &str, max_px: i32, animate: bool) -> Option with_decode_slot(|| load_scaled_inner(path, max_px, animate)) } +/// Decode into an exact logical canvas. `cover` center-crops photos; contain +/// keeps the full image on a transparent canvas (stickers/icons). Returning +/// fixed-size frames prevents GtkPicture from re-negotiating the row size from +/// the source image's intrinsic dimensions after the async decode completes. +pub fn load_fitted( + path: &str, + width_px: i32, + height_px: i32, + cover: bool, + animate: bool, +) -> Option { + with_decode_slot(|| { + let width_px = width_px.max(1); + let height_px = height_px.max(1); + let decode_max = Pixbuf::file_info(path) + .map(|(_, source_w, source_h)| { + let source_w = source_w.max(1) as f64; + let source_h = source_h.max(1) as f64; + let sx = width_px as f64 / source_w; + let sy = height_px as f64 / source_h; + let scale = if cover { sx.max(sy) } else { sx.min(sy) }; + ((source_w * scale).max(source_h * scale)).ceil() as i32 + }) + .unwrap_or_else(|| width_px.max(height_px) * 2) + .max(width_px.max(height_px)); + let mut decoded = load_scaled_inner(path, decode_max, animate)?; + decoded.frames = decoded + .frames + .into_iter() + .filter_map(|frame| fit_frame(frame, width_px, height_px, cover)) + .collect(); + (!decoded.frames.is_empty()).then_some(decoded) + }) +} + fn load_scaled_inner(path: &str, max_px: i32, animate: bool) -> Option { let meta = std::fs::metadata(path).ok()?; if meta.len() < 32 { @@ -145,6 +180,53 @@ fn decode_static(path: &str, max_px: i32) -> Option { }) } +/// Decode a profile image into an exact, center-cropped RGBA square. Only the +/// Send-safe raw bytes leave the worker thread; GTK objects stay on GTK's thread. +pub fn load_square(path: &str, size_px: i32) -> Option { + load_fitted(path, size_px, size_px, true, false)? + .frames + .into_iter() + .next() +} + +fn fit_frame(frame: RawFrame, width_px: i32, height_px: i32, cover: bool) -> Option { + let source_w = frame.width.max(1) as u32; + let source_h = frame.height.max(1) as u32; + let target_w = width_px.max(1) as u32; + let target_h = height_px.max(1) as u32; + let source = image::RgbaImage::from_raw(source_w, source_h, frame.rgba)?; + let sx = target_w as f64 / source_w as f64; + let sy = target_h as f64 / source_h as f64; + let scale = if cover { sx.max(sy) } else { sx.min(sy) }; + let resized_w = ((source_w as f64 * scale).round() as u32).max(1); + let resized_h = ((source_h as f64 * scale).round() as u32).max(1); + let resized = image::imageops::resize( + &source, + resized_w, + resized_h, + image::imageops::FilterType::Triangle, + ); + let rgba = if cover { + let x = resized_w.saturating_sub(target_w) / 2; + let y = resized_h.saturating_sub(target_h) / 2; + image::imageops::crop_imm(&resized, x, y, target_w, target_h) + .to_image() + .into_raw() + } else { + let mut canvas = image::RgbaImage::new(target_w, target_h); + let x = target_w.saturating_sub(resized_w) / 2; + let y = target_h.saturating_sub(resized_h) / 2; + image::imageops::overlay(&mut canvas, &resized, i64::from(x), i64::from(y)); + canvas.into_raw() + }; + Some(RawFrame { + rgba, + width: target_w as i32, + height: target_h as i32, + delay_ms: frame.delay_ms, + }) +} + fn pixbuf_to_rgba(pb: &Pixbuf) -> Option> { let w = pb.width() as usize; let h = pb.height() as usize; @@ -366,3 +448,43 @@ fn expand_to_rgba( _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn striped_frame() -> RawFrame { + let mut rgba = Vec::new(); + for _ in 0..2 { + for value in [10, 20, 30, 40] { + rgba.extend_from_slice(&[value, 0, 0, 255]); + } + } + RawFrame { + rgba, + width: 4, + height: 2, + delay_ms: 75, + } + } + + #[test] + fn cover_produces_exact_center_cropped_canvas() { + let frame = fit_frame(striped_frame(), 2, 2, true).unwrap(); + assert_eq!((frame.width, frame.height), (2, 2)); + assert_eq!(frame.rgba.len(), 2 * 2 * 4); + assert_eq!(frame.rgba[0], 20); + assert_eq!(frame.rgba[4], 30); + assert_eq!(frame.delay_ms, 75); + } + + #[test] + fn contain_produces_exact_transparent_letterbox_canvas() { + let frame = fit_frame(striped_frame(), 4, 4, false).unwrap(); + assert_eq!((frame.width, frame.height), (4, 4)); + assert_eq!(frame.rgba.len(), 4 * 4 * 4); + assert_eq!(frame.rgba[3], 0); + assert_eq!(frame.rgba[(4 * 4) + 3], 255); + assert_eq!(frame.rgba[(3 * 4 * 4) + 3], 0); + } +} diff --git a/src/ui/backgrounds.rs b/src/ui/backgrounds.rs new file mode 100644 index 0000000..5b94814 --- /dev/null +++ b/src/ui/backgrounds.rs @@ -0,0 +1,345 @@ +use super::*; +use crate::config::{ChatBackgroundConfig, ensure_private_dir}; + +const BACKGROUND_CLASSES: &[&str] = &[ + "line-chat-bg-default", + "line-chat-bg-mint", + "line-chat-bg-ocean", + "line-chat-bg-dusk", + "line-chat-bg-graphite", +]; + +const BACKGROUND_CHOICES: &[(&str, &str, &str)] = &[ + ( + "default", + "chat_background_default", + "line-bg-swatch-default", + ), + ("mint", "chat_background_mint", "line-bg-swatch-mint"), + ("ocean", "chat_background_ocean", "line-bg-swatch-ocean"), + ("dusk", "chat_background_dusk", "line-bg-swatch-dusk"), + ( + "graphite", + "chat_background_graphite", + "line-bg-swatch-graphite", + ), +]; + +pub(super) fn wire_chat_background_actions(state: &AppState) { + let state = state.clone(); + state.background_btn.clone().connect_clicked(move |_| { + fill_background_popover(&state); + state.background_popover.popup(); + }); +} + +pub(super) fn apply_chat_background(state: &AppState, chat_mid: &str) { + for class in BACKGROUND_CLASSES { + state.message_background_layer.remove_css_class(class); + } + state + .message_background_layer + .add_css_class("line-chat-bg-default"); + state + .message_background_picture + .set_paintable(None::<&gdk::Paintable>); + state.message_background_picture.set_visible(false); + + let setting = state + .config + .borrow() + .chat_backgrounds + .get(chat_mid) + .cloned() + .unwrap_or_default(); + if setting.preset == "custom" && std::path::Path::new(&setting.image_path).is_file() { + state.message_background_picture.set_visible(true); + load_custom_background(state, chat_mid.to_string(), setting.image_path.clone()); + } else if setting.preset != "default" { + state + .message_background_layer + .remove_css_class("line-chat-bg-default"); + state + .message_background_layer + .add_css_class(&format!("line-chat-bg-{}", setting.preset)); + } +} + +fn load_custom_background(state: &AppState, chat_mid: String, path: String) { + let (tx, rx) = async_channel::bounded::>(1); + let load_path = path.clone(); + std::thread::spawn(move || { + let _ = tx.send_blocking(crate::sticker_anim::load_scaled(&load_path, 1600, false)); + }); + let state = state.clone(); + glib::spawn_future_local(async move { + let loaded = rx.recv().await; + let still_selected = state.current_chat.borrow().as_deref() == Some(chat_mid.as_str()) + && state + .config + .borrow() + .chat_backgrounds + .get(&chat_mid) + .is_some_and(|background| { + background.preset == "custom" && background.image_path == path + }); + if !still_selected { + return; + } + match loaded { + Ok(Some(frames)) => { + apply_frames_to_picture(&state.message_background_picture, frames, false) + } + _ => { + state.message_background_picture.set_visible(false); + toast(&state, &crate::i18n::t("chat_background_failed")); + } + } + }); +} + +fn fill_background_popover(state: &AppState) { + let Some(chat_mid) = state.current_chat.borrow().clone() else { + return; + }; + let chat_name = state + .chats + .borrow() + .iter() + .find(|chat| chat.mid == chat_mid) + .map(|chat| chat.name.clone()) + .unwrap_or_else(|| chat_mid.clone()); + let selected = state + .config + .borrow() + .chat_backgrounds + .get(&chat_mid) + .cloned() + .unwrap_or_default(); + + let root = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .margin_top(12) + .margin_bottom(12) + .margin_start(12) + .margin_end(12) + .css_classes(["line-chat-background-menu"]) + .build(); + root.append( + >k::Label::builder() + .label(crate::i18n::t("chat_background")) + .xalign(0.0) + .css_classes(["heading"]) + .build(), + ); + root.append( + >k::Label::builder() + .label(chat_name) + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::End) + .css_classes(["caption", "dim-label"]) + .build(), + ); + + let choices = gtk::Grid::builder() + .column_spacing(8) + .row_spacing(8) + .column_homogeneous(true) + .build(); + for (index, (preset, label_key, swatch_class)) in BACKGROUND_CHOICES.iter().enumerate() { + let selected_now = selected.preset == *preset; + let button = background_choice_button(label_key, swatch_class, selected_now); + let state = state.clone(); + let chat_mid = chat_mid.clone(); + let preset = (*preset).to_string(); + button.connect_clicked(move |_| { + set_background_preset(&state, &chat_mid, &preset); + state.background_popover.popdown(); + }); + choices.attach(&button, (index % 2) as i32, (index / 2) as i32, 1, 1); + } + root.append(&choices); + root.append(>k::Separator::new(gtk::Orientation::Horizontal)); + + let custom = gtk::Button::builder() + .halign(gtk::Align::Fill) + .css_classes(if selected.preset == "custom" { + vec!["flat", "line-chat-background-custom", "suggested-action"] + } else { + vec!["flat", "line-chat-background-custom"] + }) + .build(); + let custom_content = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .build(); + custom_content.append(>k::Image::from_icon_name("folder-pictures-symbolic")); + custom_content.append( + >k::Label::builder() + .label(crate::i18n::t("chat_background_choose_image")) + .xalign(0.0) + .hexpand(true) + .build(), + ); + if selected.preset == "custom" { + custom_content.append(>k::Image::from_icon_name("object-select-symbolic")); + } + custom.set_child(Some(&custom_content)); + { + let state = state.clone(); + let chat_mid = chat_mid.clone(); + custom.connect_clicked(move |_| { + state.background_popover.popdown(); + choose_custom_background(&state, &chat_mid); + }); + } + root.append(&custom); + state.background_popover.set_child(Some(&root)); +} + +fn background_choice_button(label_key: &str, swatch_class: &str, selected: bool) -> gtk::Button { + let button = gtk::Button::builder() + .css_classes(if selected { + vec![ + "flat", + "line-chat-bg-choice", + "line-chat-bg-choice-selected", + ] + } else { + vec!["flat", "line-chat-bg-choice"] + }) + .build(); + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .build(); + content.append( + >k::Box::builder() + .width_request(36) + .height_request(28) + .css_classes(["line-chat-bg-swatch", swatch_class]) + .build(), + ); + content.append( + >k::Label::builder() + .label(crate::i18n::t(label_key)) + .xalign(0.0) + .hexpand(true) + .build(), + ); + content.append( + >k::Image::builder() + .icon_name("object-select-symbolic") + .visible(selected) + .build(), + ); + button.set_child(Some(&content)); + button +} + +fn set_background_preset(state: &AppState, chat_mid: &str, preset: &str) { + { + let mut config = state.config.borrow_mut(); + if preset == "default" { + config.chat_backgrounds.remove(chat_mid); + } else { + config.chat_backgrounds.insert( + chat_mid.to_string(), + ChatBackgroundConfig { + preset: preset.to_string(), + image_path: String::new(), + }, + ); + } + config.save(&state.data_dir); + } + if state.current_chat.borrow().as_deref() == Some(chat_mid) { + apply_chat_background(state, chat_mid); + } +} + +fn choose_custom_background(state: &AppState, chat_mid: &str) { + let filter = gtk::FileFilter::new(); + filter.set_name(Some(&crate::i18n::t("chat_background_images"))); + filter.add_mime_type("image/png"); + filter.add_mime_type("image/jpeg"); + filter.add_mime_type("image/webp"); + filter.add_mime_type("image/gif"); + let filters = gio::ListStore::new::(); + filters.append(&filter); + let dialog = gtk::FileDialog::builder() + .title(crate::i18n::t("chat_background_choose_image")) + .modal(true) + .filters(&filters) + .default_filter(&filter) + .build(); + let state = state.clone(); + let window = state.window.clone(); + let chat_mid = chat_mid.to_string(); + dialog.open(Some(&window), None::<&gio::Cancellable>, move |result| { + let Ok(file) = result else { + return; + }; + let Some(source) = file.path() else { + toast(&state, &crate::i18n::t("chat_background_failed")); + return; + }; + match cache_background_image(&state, &chat_mid, &source) { + Ok(path) => { + { + let mut config = state.config.borrow_mut(); + config.chat_backgrounds.insert( + chat_mid.clone(), + ChatBackgroundConfig { + preset: "custom".into(), + image_path: path.to_string_lossy().to_string(), + }, + ); + config.save(&state.data_dir); + } + if state.current_chat.borrow().as_deref() == Some(chat_mid.as_str()) { + apply_chat_background(&state, &chat_mid); + } + toast(&state, &crate::i18n::t("chat_background_saved")); + } + Err(error) => toast( + &state, + &crate::i18n::tf( + "chat_background_save_failed", + &[("error", &error.to_string())], + ), + ), + } + }); +} + +fn cache_background_image( + state: &AppState, + chat_mid: &str, + source: &std::path::Path, +) -> anyhow::Result { + let metadata = std::fs::metadata(source)?; + anyhow::ensure!(metadata.is_file(), "not a file"); + anyhow::ensure!( + metadata.len() <= 32 * 1024 * 1024, + "image is larger than 32 MB" + ); + anyhow::ensure!( + gdk_pixbuf::Pixbuf::file_info(source).is_some(), + "unsupported image" + ); + let dir = state.data_dir.join("backgrounds"); + ensure_private_dir(&dir)?; + let safe_mid: String = chat_mid + .chars() + .filter(|character| character.is_ascii_alphanumeric() || *character == '-') + .take(80) + .collect(); + anyhow::ensure!(!safe_mid.is_empty(), "invalid chat id"); + let destination = dir.join(format!("{safe_mid}.image")); + let temporary = dir.join(format!(".{safe_mid}.image.tmp")); + std::fs::copy(source, &temporary)?; + std::fs::rename(&temporary, &destination)?; + Ok(destination) +} diff --git a/src/ui/call_window.rs b/src/ui/call_window.rs index fefdd9b..c514f5c 100644 --- a/src/ui/call_window.rs +++ b/src/ui/call_window.rs @@ -13,6 +13,7 @@ pub struct CallUi { pub timer_label: gtk::Label, pub mute_btn: gtk::ToggleButton, pub deafen_btn: gtk::ToggleButton, + pub screen_btn: gtk::ToggleButton, pub hangup_btn: gtk::Button, pub answer_btn: gtk::Button, pub decline_btn: gtk::Button, @@ -166,6 +167,14 @@ pub fn open_call_window( .build(); deafen_btn.set_size_request(52, 52); + let screen_btn = gtk::ToggleButton::builder() + .icon_name("video-display-symbolic") + .tooltip_text(crate::i18n::t("call_share_screen")) + .css_classes(["circular", "line-call-ctl"]) + .sensitive(false) + .build(); + screen_btn.set_size_request(52, 52); + let hangup_btn = gtk::Button::builder() .icon_name("call-stop-symbolic") .tooltip_text(crate::i18n::t("call_hangup")) @@ -174,6 +183,7 @@ pub fn open_call_window( hangup_btn.set_size_request(64, 64); out_box.append(&mute_btn); + out_box.append(&screen_btn); out_box.append(&hangup_btn); out_box.append(&deafen_btn); @@ -220,6 +230,7 @@ pub fn open_call_window( timer_label, mute_btn, deafen_btn, + screen_btn, hangup_btn, answer_btn, decline_btn, @@ -313,6 +324,33 @@ pub fn update_deafen_visual(ui: &CallUi, deafened: bool) { } } +pub fn set_screen_capable(ui: &CallUi, capable: bool) { + ui.screen_btn.set_sensitive(capable); + if !capable { + ui.screen_btn.set_active(false); + ui.screen_btn + .set_tooltip_text(Some(&crate::i18n::t("call_share_unavailable"))); + } else { + ui.screen_btn + .set_tooltip_text(Some(&crate::i18n::t("call_share_screen"))); + } +} + +pub fn update_screen_visual(ui: &CallUi, sharing: bool) { + if ui.screen_btn.is_active() != sharing { + ui.screen_btn.set_active(sharing); + } + if sharing { + ui.screen_btn.set_icon_name("media-playback-stop-symbolic"); + ui.screen_btn + .set_tooltip_text(Some(&crate::i18n::t("call_stop_sharing"))); + } else { + ui.screen_btn.set_icon_name("video-display-symbolic"); + ui.screen_btn + .set_tooltip_text(Some(&crate::i18n::t("call_share_screen"))); + } +} + pub fn wire_close_hangup(ui: &CallUi, hangup: impl Fn() + 'static) { let guard = ui.hangup_guard.clone(); ui.window.connect_close_request(move |_| { diff --git a/src/ui/calls.rs b/src/ui/calls.rs index 495395a..e58ff50 100644 --- a/src/ui/calls.rs +++ b/src/ui/calls.rs @@ -21,7 +21,7 @@ pub(super) fn refresh_call_controls(state: &AppState) { .borrow() .iter() .find(|c| &c.mid == mid) - .map(|c| c.mid.starts_with('u') && c.kind != "bot") + .map(|c| c.mid.starts_with('u') && c.kind != "bot" && c.kind != "keep") }) .unwrap_or(false); state.call_btn.set_sensitive(can_call); @@ -72,6 +72,7 @@ pub(super) fn start_voice_call(state: &AppState) { &state.config.borrow().audio_output, state.config.borrow().call_mic_volume, state.config.borrow().call_spk_volume, + true, ) { Ok(_) => { *state.active_call_peer.borrow_mut() = Some(mid.clone()); @@ -186,6 +187,41 @@ pub(super) fn ensure_call_window(state: &AppState, peer_name: &str, mode: CallMo } }); } + { + let s = state.clone(); + ui.screen_btn.connect_toggled(move |btn| { + let sharing = btn.is_active(); + if *s.call_screen_sharing.borrow() == sharing { + return; + } + *s.call_screen_sharing.borrow_mut() = sharing; + let result = if sharing { + s.sidecar.call_screen_start() + } else { + s.sidecar.call_screen_stop() + }; + match result { + Ok(id) => { + let pending = if sharing { + Pending::CallScreenStart + } else { + Pending::CallScreenStop + }; + s.pending.borrow_mut().insert(id, pending); + } + Err(error) => { + *s.call_screen_sharing.borrow_mut() = false; + if let Some(ui) = s.call_ui.borrow().as_ref() { + call_window::update_screen_visual(ui, false); + } + toast( + &s, + &crate::i18n::tf("call_share_failed", &[("error", &error.to_string())]), + ); + } + } + }); + } { let s = state.clone(); ui.mic_vol.connect_value_changed(move |scale| { @@ -220,6 +256,9 @@ pub(super) fn ensure_call_window(state: &AppState, peer_name: &str, mode: CallMo } *state.call_mic_muted.borrow_mut() = false; *state.call_deafened.borrow_mut() = false; + *state.call_video_capable.borrow_mut() = false; + *state.call_screen_sharing.borrow_mut() = false; + call_window::set_screen_capable(&ui, false); *state.call_ui.borrow_mut() = Some(ui); } @@ -238,6 +277,8 @@ pub(super) fn close_active_call_ui(state: &AppState) { *state.active_call_peer.borrow_mut() = None; *state.call_mic_muted.borrow_mut() = false; *state.call_deafened.borrow_mut() = false; + *state.call_video_capable.borrow_mut() = false; + *state.call_screen_sharing.borrow_mut() = false; if let Some(ui) = state.call_ui.borrow_mut().take() { call_window::close_call_window(&ui); } @@ -248,6 +289,7 @@ pub(super) fn handle_call_state( peer: &str, call_state: &str, error: Option<&str>, + video_capable: bool, ) { if !calls_experimental_enabled(state) && matches!( @@ -271,11 +313,15 @@ pub(super) fn handle_call_state( } "connected" => { *state.active_call_peer.borrow_mut() = Some(peer.to_string()); + *state.call_video_capable.borrow_mut() = video_capable; ensure_call_window( state, &name, CallMode::Connected(&crate::i18n::tf("call_connected", &[("name", &name)])), ); + if let Some(ui) = state.call_ui.borrow().as_ref() { + call_window::set_screen_capable(ui, video_capable); + } } "ended" => { close_active_call_ui(state); @@ -295,6 +341,55 @@ pub(super) fn handle_call_state( } } +pub(super) fn handle_screen_share_state(state: &AppState, screen_state: &str, error: Option<&str>) { + match screen_state { + "selecting" => { + *state.call_screen_sharing.borrow_mut() = true; + if let Some(ui) = state.call_ui.borrow().as_ref() { + ui.status_label + .set_text(&crate::i18n::t("call_share_selecting")); + call_window::update_screen_visual(ui, true); + } + } + "starting" => { + if let Some(ui) = state.call_ui.borrow().as_ref() { + ui.status_label + .set_text(&crate::i18n::t("call_share_starting")); + } + } + "active" => { + *state.call_screen_sharing.borrow_mut() = true; + if let Some(ui) = state.call_ui.borrow().as_ref() { + ui.status_label + .set_text(&crate::i18n::t("call_share_active")); + call_window::update_screen_visual(ui, true); + } + } + "stopped" => { + *state.call_screen_sharing.borrow_mut() = false; + if let Some(ui) = state.call_ui.borrow().as_ref() { + ui.status_label + .set_text(&crate::i18n::t("call_connected_short")); + call_window::update_screen_visual(ui, false); + } + } + "failed" => { + *state.call_screen_sharing.borrow_mut() = false; + if let Some(ui) = state.call_ui.borrow().as_ref() { + call_window::update_screen_visual(ui, false); + } + toast( + state, + &crate::i18n::tf( + "call_share_failed", + &[("error", error.unwrap_or("screen share failed"))], + ), + ); + } + _ => {} + } +} + pub(super) fn notify_call_incoming(state: &AppState, name: &str) { if !notifications_allowed(state) { return; diff --git a/src/ui/chats.rs b/src/ui/chats.rs index 24dd3b7..cb433ef 100644 --- a/src/ui/chats.rs +++ b/src/ui/chats.rs @@ -1,6 +1,9 @@ use super::*; pub(super) fn apply_chats(state: &AppState, mut chats: Vec, cached: bool) { + // Removing a selected row makes GtkListBox try to focus a row that has + // already been detached. Clear selection before the rebuild. + state.chat_list.unselect_all(); clear_list(&state.chat_list); state.chat_avatars.borrow_mut().clear(); state.chat_previews.borrow_mut().clear(); @@ -10,10 +13,20 @@ pub(super) fn apply_chats(state: &AppState, mut chats: Vec, cached: bo if !chat.preview.is_empty() { chat.preview = localize_preview(&chat.preview); } + if state.config.borrow().pinned_chats.contains(&chat.mid) { + chat.pinned = true; + } } + chats.sort_by(|a, b| { + b.pinned + .cmp(&a.pinned) + .then_with(|| b.last_activity.cmp(&a.last_activity)) + .then_with(|| a.name.cmp(&b.name)) + }); for chat in &chats { let (row, avatar, preview, badge) = build_chat_row(chat, *state.sidebar_compact.borrow()); + wire_chat_row_context_menu(state, &row, chat); state .chat_avatars .borrow_mut() @@ -60,8 +73,6 @@ pub(super) fn select_sidebar_chat(state: &AppState, mid: Option<&str>) { }; if let Some(row) = state.chat_list.row_at_index(pos as i32) { state.chat_list.select_row(Some(&row)); - // Ensure the selected row is scrolled into view. - row.grab_focus(); } } @@ -94,6 +105,7 @@ pub(super) fn upsert_chat_row(state: &AppState, chat: ChatInfo) { if let Some(path) = chat.avatar_path.clone() { cur.avatar_path = Some(path); } + cur.pinned = chat.pinned; } if let Some(label) = state.chat_previews.borrow().get(&mid) && !chat.preview.is_empty() @@ -112,13 +124,15 @@ pub(super) fn upsert_chat_row(state: &AppState, chat: ChatInfo) { && std::path::Path::new(path).exists() && let Some(img) = state.chat_avatars.borrow().get(&mid).cloned() { - attach_texture_async(img, path.to_string(), 72); + let px = img.width_request().max(AVATAR_COMPACT_PX); + attach_avatar_texture_async(img, path.to_string(), px); } } return; } let (row, avatar, preview, badge) = build_chat_row(&chat, *state.sidebar_compact.borrow()); + wire_chat_row_context_menu(state, &row, &chat); state.chat_avatars.borrow_mut().insert(mid.clone(), avatar); state .chat_previews @@ -128,8 +142,16 @@ pub(super) fn upsert_chat_row(state: &AppState, chat: ChatInfo) { .chat_unread_badges .borrow_mut() .insert(mid.clone(), badge); - state.chats.borrow_mut().insert(0, chat); - state.chat_list.prepend(&row); + let insert_at = { + let chats = state.chats.borrow(); + if chat.pinned { + 0 + } else { + chats.iter().take_while(|item| item.pinned).count() + } + }; + state.chats.borrow_mut().insert(insert_at, chat); + state.chat_list.insert(&row, insert_at as i32); set_side_state(state, "list", None); let n = state.chats.borrow().len(); state.status.set_text(&crate::i18n::tf( @@ -145,17 +167,31 @@ pub(super) fn promote_chat_to_top(state: &AppState, mid: &str) { chats.iter().position(|c| c.mid == mid) }; let Some(pos) = pos else { return }; - if pos == 0 { + let target = { + let chats = state.chats.borrow(); + if chats[pos].pinned { + 0 + } else { + chats.iter().take_while(|chat| chat.pinned).count() + } + }; + if pos == target { return; } - { + let target = { let mut chats = state.chats.borrow_mut(); let chat = chats.remove(pos); - chats.insert(0, chat); - } + let target = if chat.pinned { + 0 + } else { + chats.iter().take_while(|item| item.pinned).count() + }; + chats.insert(target, chat); + target + }; if let Some(row) = state.chat_list.row_at_index(pos as i32) { state.chat_list.remove(&row); - state.chat_list.prepend(&row); + state.chat_list.insert(&row, target as i32); if state.config.borrow().animations { row.add_css_class("line-chat-bump"); let row_c = row.clone(); @@ -256,6 +292,7 @@ pub(super) fn build_chat_row( .valign(gtk::Align::Center) .css_classes(["line-avatar-frame"]) .build(); + avatar_frame.set_overflow(gtk::Overflow::Hidden); let avatar = gtk::Picture::builder() .content_fit(gtk::ContentFit::Cover) .can_shrink(true) @@ -263,10 +300,11 @@ pub(super) fn build_chat_row( .height_request(avatar_px) .css_classes(["line-avatar"]) .build(); + avatar.set_overflow(gtk::Overflow::Hidden); if let Some(path) = chat.avatar_path.as_deref() && std::path::Path::new(path).exists() { - attach_texture_async(avatar.clone(), path.to_string(), avatar_px * 2); + attach_avatar_texture_async(avatar.clone(), path.to_string(), avatar_px); } avatar_frame.append(&avatar); @@ -315,6 +353,16 @@ pub(super) fn build_chat_row( .css_classes(["dim-label", "caption"]) .build(); top.append(&name); + if chat.pinned { + top.append( + >k::Image::builder() + .icon_name("starred-symbolic") + .pixel_size(14) + .tooltip_text(crate::i18n::t("unpin_chat")) + .css_classes(["line-chat-pinned"]) + .build(), + ); + } top.append(&time); let bottom = gtk::Box::builder() @@ -347,6 +395,82 @@ pub(super) fn build_chat_row( (row, avatar, preview, badge) } +fn wire_chat_row_context_menu(state: &AppState, row: >k::ListBoxRow, chat: &ChatInfo) { + let popover = gtk::Popover::builder() + .autohide(true) + .has_arrow(false) + .position(gtk::PositionType::Right) + .build(); + let menu = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(2) + .margin_top(6) + .margin_bottom(6) + .margin_start(6) + .margin_end(6) + .css_classes(["line-chat-context-menu"]) + .build(); + let pin = gtk::Button::builder() + .halign(gtk::Align::Fill) + .css_classes(["flat", "line-chat-context-action"]) + .build(); + let pin_content = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .build(); + pin_content.append( + >k::Image::builder() + .icon_name(if chat.pinned { + "starred-symbolic" + } else { + "non-starred-symbolic" + }) + .build(), + ); + pin_content.append( + >k::Label::builder() + .label(crate::i18n::t(if chat.pinned { + "unpin_chat" + } else { + "pin_chat" + })) + .xalign(0.0) + .hexpand(true) + .build(), + ); + pin.set_child(Some(&pin_content)); + menu.append(&pin); + popover.set_child(Some(&menu)); + popover.set_parent(row); + + { + let state = state.clone(); + let mid = chat.mid.clone(); + let popover = popover.clone(); + pin.connect_clicked(move |_| { + popover.popdown(); + toggle_chat_pin_mid(&state, &mid); + }); + } + + let click = gtk::GestureClick::new(); + click.set_button(gdk::BUTTON_SECONDARY); + let popover_click = popover.clone(); + click.connect_pressed(move |gesture, _, x, y| { + popover_click.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1))); + popover_click.popup(); + gesture.set_state(gtk::EventSequenceState::Claimed); + }); + row.add_controller(click); + + let popover_cleanup = popover.clone(); + row.connect_unrealize(move |_| { + if popover_cleanup.parent().is_some() { + popover_cleanup.unparent(); + } + }); +} + pub(super) fn format_activity(ts: i64) -> String { if ts <= 0 { return String::new(); @@ -479,6 +603,152 @@ pub(super) fn toggle_chat_mute(state: &AppState) { } } +pub(super) fn update_pin_btn(state: &AppState, pinned: bool) { + state.pin_btn.set_icon_name(if pinned { + "starred-symbolic" + } else { + "non-starred-symbolic" + }); + state + .pin_btn + .set_tooltip_text(Some(&crate::i18n::t(if pinned { + "unpin_chat" + } else { + "pin_chat" + }))); +} + +pub(super) fn toggle_chat_pin(state: &AppState) { + let Some(mid) = state.current_chat.borrow().clone() else { + return; + }; + toggle_chat_pin_mid(state, &mid); +} + +pub(super) fn toggle_chat_pin_mid(state: &AppState, mid: &str) { + let next = !state + .chats + .borrow() + .iter() + .find(|chat| chat.mid == mid) + .map(|chat| chat.pinned) + .unwrap_or(false); + { + let mut config = state.config.borrow_mut(); + config.pinned_chats.retain(|item| item != mid); + if next { + config.pinned_chats.push(mid.to_string()); + } + config.save(&state.data_dir); + } + let mut chats = state.chats.borrow().clone(); + if let Some(chat) = chats.iter_mut().find(|chat| chat.mid == mid) { + chat.pinned = next; + } + apply_chats(state, chats, false); + if state.current_chat.borrow().as_deref() == Some(mid) { + update_pin_btn(state, next); + } +} + +pub(super) fn open_chat_album(state: &AppState) { + let mut messages: Vec = state + .media_msgs + .borrow() + .values() + .filter(|message| { + matches!( + message.content_type.to_ascii_uppercase().as_str(), + "IMAGE" | "VIDEO" + ) && message + .image_path + .as_deref() + .is_some_and(|path| std::path::Path::new(path).exists()) + }) + .cloned() + .collect(); + messages.sort_by_key(|message| std::cmp::Reverse(message.created_time)); + + let title = state + .current_chat + .borrow() + .as_ref() + .and_then(|mid| { + state + .chats + .borrow() + .iter() + .find(|chat| &chat.mid == mid) + .cloned() + }) + .map(|chat| format!("{} · {}", crate::i18n::t("chat_album"), chat.name)) + .unwrap_or_else(|| crate::i18n::t("chat_album")); + let window = gtk::Window::builder() + .transient_for(&state.window) + .modal(true) + .title(title) + .default_width(760) + .default_height(620) + .build(); + let scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vexpand(true) + .build(); + if messages.is_empty() { + scroll.set_child(Some( + >k::Label::builder() + .label(crate::i18n::t("chat_album_empty")) + .valign(gtk::Align::Center) + .vexpand(true) + .css_classes(["dim-label"]) + .build(), + )); + } else { + let flow = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .homogeneous(true) + .min_children_per_line(2) + .max_children_per_line(4) + .column_spacing(10) + .row_spacing(10) + .margin_top(14) + .margin_bottom(14) + .margin_start(14) + .margin_end(14) + .build(); + for message in messages { + let Some(path) = message.image_path.clone() else { + continue; + }; + let picture = gtk::Picture::builder() + .content_fit(gtk::ContentFit::Cover) + .can_shrink(true) + .width_request(160) + .height_request(160) + .css_classes(["line-album-thumb"]) + .build(); + attach_texture_async(picture.clone(), path.clone(), 360); + let button = gtk::Button::builder() + .child(&picture) + .css_classes(["flat", "line-album-thumb-btn"]) + .build(); + let state = state.clone(); + let content_type = message.content_type.clone(); + let name = message + .file_name + .clone() + .unwrap_or_else(|| format!("{}.jpg", message.id)); + button.connect_clicked(move |_| { + open_media_viewer(&state, &path, &content_type, &name); + }); + flow.insert(&button, -1); + } + scroll.set_child(Some(&flow)); + } + window.set_child(Some(&scroll)); + window.present(); +} + pub(super) fn apply_peer_read(state: &AppState, chat_mid: &str, message_id: &str) { if chat_mid.is_empty() || message_id.is_empty() { return; diff --git a/src/ui/composer_media.rs b/src/ui/composer_media.rs index b2a5800..b88dc6c 100644 --- a/src/ui/composer_media.rs +++ b/src/ui/composer_media.rs @@ -321,6 +321,7 @@ pub(super) fn send_sticker_now( file_path: None, duration_ms: None, flex: None, + ..Default::default() }, ); dismiss_new_marker(state); @@ -376,25 +377,306 @@ pub(super) fn pick_and_send_media(state: &AppState) { .build(); let s = state.clone(); - dialog.open( + dialog.open_multiple( Some(&state.window), None::<&gio::Cancellable>, move |result| { - let Ok(file) = result else { + let Ok(files) = result else { return; }; - let Some(path) = file.path() else { + let paths = paths_from_file_model(&files); + if paths.is_empty() { toast( &s, &crate::i18n::tf("media_send_failed", &[("error", "no path")]), ); return; - }; - send_local_media_path(&s, path); + } + open_media_review(&s, paths); }, ); } +fn paths_from_file_model(files: &gio::ListModel) -> Vec { + (0..files.n_items()) + .filter_map(|index| files.item(index)) + .filter_map(|item| item.downcast::().ok()) + .filter_map(|file| file.path()) + .filter(|path| path.is_file()) + .collect() +} + +fn is_previewable_image(path: &std::path::Path) -> bool { + matches!( + path.extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str(), + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "avif" + ) +} + +fn media_review_icon(path: &std::path::Path) -> &'static str { + match guess_media_o_type(path) { + "video" => "video-x-generic-symbolic", + "audio" => "audio-x-generic-symbolic", + _ => "text-x-generic-symbolic", + } +} + +fn update_media_review_controls( + queue: &Rc>>, + count: >k::Label, + send: >k::Button, + empty: >k::Label, +) { + let len = queue.borrow().len(); + let n = len.to_string(); + count.set_text(&crate::i18n::tf("media_review_count", &[("n", &n)])); + send.set_label(&crate::i18n::tf("media_review_send", &[("n", &n)])); + send.set_sensitive(len > 0); + empty.set_visible(len == 0); +} + +fn append_media_review_item( + path: PathBuf, + flow: >k::FlowBox, + queue: &Rc>>, + count: >k::Label, + send: >k::Button, + empty: >k::Label, +) { + if !path.is_file() || queue.borrow().contains(&path) { + return; + } + queue.borrow_mut().push(path.clone()); + + let preview = gtk::Overlay::builder() + .width_request(156) + .height_request(132) + .css_classes(["line-media-review-preview"]) + .build(); + preview.set_overflow(gtk::Overflow::Hidden); + if is_previewable_image(&path) { + let picture = gtk::Picture::builder() + .content_fit(gtk::ContentFit::Cover) + .can_shrink(true) + .width_request(156) + .height_request(132) + .css_classes(["line-media-review-thumb"]) + .build(); + attach_texture_async(picture.clone(), path.to_string_lossy().to_string(), 256); + preview.set_child(Some(&picture)); + } else { + let icon = gtk::Image::builder() + .icon_name(media_review_icon(&path)) + .pixel_size(52) + .css_classes(["dim-label", "line-media-review-file-icon"]) + .build(); + preview.set_child(Some(&icon)); + } + + let remove = gtk::Button::builder() + .icon_name("window-close-symbolic") + .tooltip_text(crate::i18n::t("media_review_remove")) + .halign(gtk::Align::End) + .valign(gtk::Align::Start) + .css_classes(["circular", "line-media-review-remove"]) + .build(); + preview.add_overlay(&remove); + + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("file"); + let card = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(6) + .css_classes(["line-media-review-card"]) + .tooltip_text(path.to_string_lossy()) + .build(); + card.append(&preview); + card.append( + >k::Label::builder() + .label(file_name) + .ellipsize(gtk::pango::EllipsizeMode::Middle) + .max_width_chars(22) + .xalign(0.5) + .css_classes(["caption"]) + .build(), + ); + + let child = gtk::FlowBoxChild::new(); + child.set_child(Some(&card)); + flow.append(&child); + + let flow_c = flow.clone(); + let child_c = child.clone(); + let queue_c = queue.clone(); + let count_c = count.clone(); + let send_c = send.clone(); + let empty_c = empty.clone(); + remove.connect_clicked(move |_| { + queue_c.borrow_mut().retain(|queued| queued != &path); + flow_c.remove(&child_c); + update_media_review_controls(&queue_c, &count_c, &send_c, &empty_c); + }); + update_media_review_controls(queue, count, send, empty); +} + +pub(super) fn open_media_review(state: &AppState, paths: Vec) { + let window = gtk::Window::builder() + .title(crate::i18n::t("media_review_title")) + .transient_for(&state.window) + .modal(true) + .default_width(680) + .default_height(520) + .css_classes(["line-media-review-window"]) + .build(); + + let header = gtk::HeaderBar::builder().show_title_buttons(true).build(); + let title = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(1) + .build(); + title.append( + >k::Label::builder() + .label(crate::i18n::t("media_review_title")) + .css_classes(["heading"]) + .build(), + ); + title.append( + >k::Label::builder() + .label(crate::i18n::t("media_review_hint")) + .css_classes(["caption", "dim-label"]) + .build(), + ); + header.set_title_widget(Some(&title)); + + let add = gtk::Button::builder() + .label(crate::i18n::t("media_review_add")) + .tooltip_text(crate::i18n::t("media_review_add")) + .build(); + header.pack_start(&add); + + let flow = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .min_children_per_line(2) + .max_children_per_line(4) + .row_spacing(12) + .column_spacing(12) + .homogeneous(true) + .valign(gtk::Align::Start) + .css_classes(["line-media-review-grid"]) + .build(); + let empty = gtk::Label::builder() + .label(crate::i18n::t("media_review_empty")) + .visible(false) + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .vexpand(true) + .css_classes(["dim-label"]) + .build(); + let content = gtk::Overlay::new(); + content.set_child(Some( + >k::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vscrollbar_policy(gtk::PolicyType::Automatic) + .vexpand(true) + .child(&flow) + .build(), + )); + content.add_overlay(&empty); + + let count = gtk::Label::builder() + .halign(gtk::Align::Start) + .hexpand(true) + .css_classes(["dim-label"]) + .build(); + let cancel = gtk::Button::builder() + .label(crate::i18n::t("cancel")) + .build(); + let send = gtk::Button::builder() + .css_classes(["suggested-action"]) + .build(); + let footer = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(10) + .margin_top(12) + .margin_bottom(12) + .margin_start(16) + .margin_end(16) + .css_classes(["line-media-review-footer"]) + .build(); + footer.append(&count); + footer.append(&cancel); + footer.append(&send); + + let root = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .css_classes(["line-media-review"]) + .build(); + root.append(&header); + root.append(&content); + root.append(&footer); + window.set_child(Some(&root)); + + let queue = Rc::new(RefCell::new(Vec::new())); + for path in paths { + append_media_review_item(path, &flow, &queue, &count, &send, &empty); + } + update_media_review_controls(&queue, &count, &send, &empty); + + { + let window = window.clone(); + cancel.connect_clicked(move |_| window.close()); + } + { + let window = window.clone(); + let state = state.clone(); + let queue = queue.clone(); + send.connect_clicked(move |_| { + let files = queue.borrow().clone(); + if files.is_empty() { + return; + } + window.close(); + for path in files { + send_local_media_path(&state, path); + } + }); + } + { + let parent = window.clone(); + let flow = flow.clone(); + let queue = queue.clone(); + let count = count.clone(); + let send = send.clone(); + let empty = empty.clone(); + add.connect_clicked(move |_| { + let dialog = gtk::FileDialog::builder() + .title(crate::i18n::t("media_review_add")) + .modal(true) + .build(); + let flow = flow.clone(); + let queue = queue.clone(); + let count = count.clone(); + let send = send.clone(); + let empty = empty.clone(); + dialog.open_multiple(Some(&parent), None::<&gio::Cancellable>, move |result| { + let Ok(files) = result else { + return; + }; + for path in paths_from_file_model(&files) { + append_media_review_item(path, &flow, &queue, &count, &send, &empty); + } + }); + }); + } + window.present(); +} + /// Ctrl+V attachment: files (URI list / FileList) or a clipboard image. /// Returns true when paste was handled so text paste is suppressed. pub(super) fn try_paste_clipboard_attachment(state: &AppState) -> bool { @@ -407,15 +689,18 @@ pub(super) fn try_paste_clipboard_attachment(state: &AppState) -> bool { let clipboard = state.composer.clipboard(); let formats = clipboard.formats(); - let has_files = formats.contains_type(gdk::FileList::static_type()) - || formats.contain_mime_type("text/uri-list"); + let has_file_list = formats.contains_type(gdk::FileList::static_type()); + let has_uri_list = formats.contain_mime_type("text/uri-list"); let has_image = formats.contains_type(gdk::Texture::static_type()) - || formats.contain_mime_type("image/png") - || formats.contain_mime_type("image/jpeg") - || formats.contain_mime_type("image/bmp") - || formats.contain_mime_type("image/tiff"); + || formats + .mime_types() + .iter() + .any(|mime| mime.as_str().starts_with("image/")); - if has_files { + // A browser often advertises both text/uri-list and image/png when copying + // a picture. Only prefer the file path route for a real GdkFileList; + // otherwise the URI is usually a remote web URL and silently yields no file. + if has_file_list { let s = state.clone(); clipboard.read_value_async( gdk::FileList::static_type(), @@ -424,46 +709,71 @@ pub(super) fn try_paste_clipboard_attachment(state: &AppState) -> bool { move |res| match res { Ok(value) => { if let Ok(list) = value.get::() { - let mut any = false; + let mut paths = Vec::new(); for file in list.files() { if let Some(path) = file.path() && path.is_file() { - send_local_media_path(&s, path); - any = true; + paths.push(path); } } - if !any { - paste_clipboard_uri_list(&s); + if paths.is_empty() { + if has_image { + paste_clipboard_image(&s); + } else if has_uri_list { + paste_clipboard_uri_list(&s); + } + } else { + open_media_review(&s, paths); } } else { + if has_image { + paste_clipboard_image(&s); + } else if has_uri_list { + paste_clipboard_uri_list(&s); + } + } + } + Err(_) => { + if has_image { + paste_clipboard_image(&s); + } else if has_uri_list { paste_clipboard_uri_list(&s); } } - Err(_) => paste_clipboard_uri_list(&s), }, ); return true; } if has_image { - let s = state.clone(); - clipboard.read_texture_async(None::<&gio::Cancellable>, move |res| match res { - Ok(Some(tex)) => match save_clipboard_texture_png(&s, &tex) { - Ok(path) => send_local_media_path(&s, path), - Err(e) => toast( - &s, - &crate::i18n::tf("media_send_failed", &[("error", &e.to_string())]), - ), - }, - _ => paste_clipboard_image_bytes(&s), - }); + paste_clipboard_image(state); + return true; + } + + if has_uri_list { + paste_clipboard_uri_list(state); return true; } false } +fn paste_clipboard_image(state: &AppState) { + let clipboard = state.composer.clipboard(); + let s = state.clone(); + clipboard.read_texture_async(None::<&gio::Cancellable>, move |res| match res { + Ok(Some(tex)) => match save_clipboard_texture_png(&s, &tex) { + Ok(path) => open_media_review(&s, vec![path]), + Err(e) => toast( + &s, + &crate::i18n::tf("media_send_failed", &[("error", &e.to_string())]), + ), + }, + _ => paste_clipboard_image_bytes(&s), + }); +} + pub(super) fn paste_clipboard_uri_list(state: &AppState) { let clipboard = state.composer.clipboard(); let s = state.clone(); @@ -481,6 +791,7 @@ pub(super) fn paste_clipboard_uri_list(state: &AppState) { if input.read_to_string(&mut buf).is_err() { return; } + let mut paths = Vec::new(); for line in buf.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { @@ -490,9 +801,12 @@ pub(super) fn paste_clipboard_uri_list(state: &AppState) { if let Some(path) = file.path() && path.is_file() { - send_local_media_path(&s, path); + paths.push(path); } } + if !paths.is_empty() { + open_media_review(&s, paths); + } }, ); } @@ -501,7 +815,16 @@ pub(super) fn paste_clipboard_image_bytes(state: &AppState) { let clipboard = state.composer.clipboard(); let s = state.clone(); clipboard.read_async( - &["image/png", "image/jpeg", "image/bmp", "image/tiff"], + &[ + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "image/avif", + "image/bmp", + "image/x-MS-bmp", + "image/tiff", + ], glib::Priority::DEFAULT, None::<&gio::Cancellable>, move |res| { @@ -516,6 +839,12 @@ pub(super) fn paste_clipboard_image_bytes(state: &AppState) { } let ext = if mime.as_str().contains("jpeg") { "jpg" + } else if mime.as_str().contains("webp") { + "webp" + } else if mime.as_str().contains("gif") { + "gif" + } else if mime.as_str().contains("avif") { + "avif" } else if mime.as_str().contains("bmp") { "bmp" } else if mime.as_str().contains("tiff") { @@ -524,7 +853,7 @@ pub(super) fn paste_clipboard_image_bytes(state: &AppState) { "png" }; match write_clipboard_bytes(&s, &bytes, ext) { - Ok(path) => send_local_media_path(&s, path), + Ok(path) => open_media_review(&s, vec![path]), Err(e) => toast( &s, &crate::i18n::tf("media_send_failed", &[("error", &e.to_string())]), @@ -663,6 +992,7 @@ pub(super) fn send_local_media_path(state: &AppState, path: PathBuf) { file_path, duration_ms: duration_ms.map(|v| v as i64), flex: None, + ..Default::default() }, ); show_upload_progress(state, 0.02, &crate::i18n::t("media_uploading")); @@ -877,6 +1207,7 @@ pub(super) fn finish_voice_record(state: &AppState, send: bool) { file_path: Some(path_str), duration_ms: Some(duration_ms as i64), flex: None, + ..Default::default() }, ); show_upload_progress(state, 0.02, &crate::i18n::t("media_uploading")); diff --git a/src/ui/diagnostics.rs b/src/ui/diagnostics.rs index 17e3251..0e2717a 100644 --- a/src/ui/diagnostics.rs +++ b/src/ui/diagnostics.rs @@ -63,7 +63,14 @@ pub fn open(parent: &impl IsA, sidecar: Rc, data_dir: &Pat let dependencies = libadwaita::PreferencesGroup::builder() .title(crate::i18n::t("diagnostics_dependencies")) .build(); - for program in ["ffmpeg", "ffprobe", "pdftoppm", "pdftotext"] { + for program in [ + "ffmpeg", + "ffprobe", + "wf-recorder", + "slurp", + "pdftoppm", + "pdftotext", + ] { dependencies.add(&row(program, &program_version(program))); } dependencies.add(&row( @@ -137,7 +144,12 @@ fn row(title: &str, subtitle: &str) -> libadwaita::ActionRow { } fn program_version(program: &str) -> String { - command_output(program, &["--version"]) + let args = if program == "slurp" { + &["-h"][..] + } else { + &["--version"][..] + }; + command_output(program, args) .and_then(|output| output.lines().next().map(str::to_string)) .unwrap_or_else(|| crate::i18n::t("diagnostics_not_found")) } diff --git a/src/ui/events.rs b/src/ui/events.rs index 1ac07c7..3773784 100644 --- a/src/ui/events.rs +++ b/src/ui/events.rs @@ -1,5 +1,72 @@ use super::*; +fn remember_message_snapshot(state: &AppState, chat_mid: &str, messages: &[MessageInfo]) { + state + .message_cache + .borrow_mut() + .insert(chat_mid.to_string(), messages.to_vec()); +} + +/// Merge one live row into the in-process cache. Returns true when this exact +/// message id was already present (polling can deliver the same operation more +/// than once during reconnect/recovery). +fn remember_live_message(state: &AppState, chat_mid: &str, message: &MessageInfo) -> bool { + let mut cache = state.message_cache.borrow_mut(); + let messages = cache.entry(chat_mid.to_string()).or_default(); + if !message.id.is_empty() + && let Some(previous) = messages.iter_mut().find(|row| row.id == message.id) + { + let image_path = message + .image_path + .clone() + .or_else(|| previous.image_path.clone()); + let audio_path = message + .audio_path + .clone() + .or_else(|| previous.audio_path.clone()); + let file_path = message + .file_path + .clone() + .or_else(|| previous.file_path.clone()); + *previous = MessageInfo { + image_path, + audio_path, + file_path, + ..message.clone() + }; + return true; + } + messages.push(message.clone()); + messages.sort_by_key(|row| row.created_time); + false +} + +fn remember_media_paths( + state: &AppState, + chat_mid: &str, + message_id: &str, + image_path: &str, + audio_path: Option<&str>, + file_path: Option<&str>, +) { + let mut cache = state.message_cache.borrow_mut(); + let Some(message) = cache + .get_mut(chat_mid) + .and_then(|messages| messages.iter_mut().find(|row| row.id == message_id)) + else { + return; + }; + if !image_path.is_empty() { + message.image_path = Some(image_path.to_string()); + } + if let Some(path) = audio_path { + message.audio_path = Some(path.to_string()); + } + if let Some(path) = file_path { + message.file_path = Some(path.to_string()); + } +} + pub(super) fn pump_events(state: AppState) { let rx = state.sidecar.events.clone(); glib::spawn_future_local(async move { @@ -128,20 +195,35 @@ pub(super) fn handle_event(state: &AppState, ev: ProtocolEvent) { peer, state: call_state, error, + video_capable, } => { tracing::debug!(%call_id, %peer, state = %call_state, ?error, "call state changed"); - handle_call_state(state, &peer, &call_state, error.as_deref()); + handle_call_state(state, &peer, &call_state, error.as_deref(), video_capable); + } + ProtocolEvent::ScreenShareState { + state: screen_state, + error, + } => { + handle_screen_share_state(state, &screen_state, error.as_deref()); } ProtocolEvent::Message(msg) => { - let peer = if msg.mine { + let peer = if !msg.chat_mid.is_empty() { + msg.chat_mid.clone() + } else if msg.mine { msg.to.clone() } else { msg.from.clone() }; + if remember_live_message(state, &peer, &msg) { + tracing::debug!(message_id = %msg.id, chat_mid = %peer, "ignored duplicate live message"); + return; + } let mut preview = format!( "{}: {}", if msg.mine { crate::i18n::t("you") + } else if !msg.sender_name.is_empty() { + msg.sender_name.clone() } else { crate::i18n::t("they") }, @@ -155,7 +237,9 @@ pub(super) fn handle_event(state: &AppState, ev: ProtocolEvent) { &ChatInfo { mid: peer.clone(), name: peer_display_name(state, &peer), - kind: if peer.starts_with('c') { + kind: if peer.starts_with('m') { + "openchat".into() + } else if peer.starts_with('c') { "group".into() } else { "dm".into() @@ -165,6 +249,7 @@ pub(super) fn handle_event(state: &AppState, ev: ProtocolEvent) { unread: 0, preview: preview.clone(), muted: false, + pinned: false, }, ); @@ -234,28 +319,39 @@ pub(super) fn handle_event(state: &AppState, ev: ProtocolEvent) { apply_chats(state, chats, cached); } ProtocolEvent::Messages { chat_mid, messages } => { + remember_message_snapshot(state, &chat_mid, &messages); if state.current_chat.borrow().as_deref() == Some(chat_mid.as_str()) { apply_messages(state, messages); } } + ProtocolEvent::StickersUpdated { result } => { + if state.sticker_popover.is_visible() { + fill_sticker_popover(state, &result); + } + } ProtocolEvent::AvatarReady { mid, avatar_path } => { if state.self_mid.borrow().as_deref() == Some(mid.as_str()) { *state.self_avatar_path.borrow_mut() = Some(avatar_path.clone()); if std::path::Path::new(&avatar_path).exists() { - attach_texture_async(state.profile_avatar.clone(), avatar_path.clone(), 64); + attach_avatar_texture_async( + state.profile_avatar.clone(), + avatar_path.clone(), + 26, + ); } sync_discord_rpc(state); } if let Some(img) = state.chat_avatars.borrow().get(&mid).cloned() && std::path::Path::new(&avatar_path).exists() { - attach_texture_async(img, avatar_path.clone(), 72); + let px = img.width_request().max(AVATAR_COMPACT_PX); + attach_avatar_texture_async(img, avatar_path.clone(), px); } if let Some(ui) = state.friends_ui.borrow().as_ref() { if let Some(img) = ui.avatars.borrow().get(&mid).cloned() && std::path::Path::new(&avatar_path).exists() { - attach_texture_async(img, avatar_path.clone(), 80); + attach_avatar_texture_async(img, avatar_path.clone(), 40); } if let Some(f) = ui.friends.borrow_mut().iter_mut().find(|c| c.mid == mid) { f.avatar_path = Some(avatar_path.clone()); @@ -309,6 +405,14 @@ pub(super) fn handle_event(state: &AppState, ev: ProtocolEvent) { audio_path, file_path, } => { + remember_media_paths( + state, + &chat_mid, + &message_id, + &image_path, + audio_path.as_deref(), + file_path.as_deref(), + ); // Always remember hydrated paths so list rebuilds / late bubbles can attach. if !image_path.is_empty() && std::path::Path::new(&image_path).exists() { state @@ -521,6 +625,58 @@ pub(super) fn handle_event(state: &AppState, ev: ProtocolEvent) { remove_pending_placeholder(state, placeholder_id); } let err = error.as_deref().unwrap_or("request failed"); + if let Some(Pending::ProfileLookup(ui)) = pending.as_ref() { + let message = crate::i18n::tf("profile_check_failed", &[("error", err)]); + ui.status.set_text(&message); + ui.add_btn.set_sensitive(false); + ui.chat_btn.set_sensitive(false); + toast(state, &message); + return; + } + if let Some(Pending::ProfileAddFriend(ui)) = pending.as_ref() { + let message = crate::i18n::tf("profile_add_failed", &[("error", err)]); + ui.status.set_text(&message); + ui.add_btn.set_sensitive(true); + ui.chat_btn.set_sensitive(false); + toast(state, &message); + return; + } + if matches!(pending, Some(Pending::CallScreenStart)) { + *state.call_screen_sharing.borrow_mut() = false; + if let Some(ui) = state.call_ui.borrow().as_ref() { + call_window::update_screen_visual(ui, false); + } + toast( + state, + &crate::i18n::tf("call_share_failed", &[("error", err)]), + ); + return; + } + if matches!(pending, Some(Pending::CallScreenStop)) { + *state.call_screen_sharing.borrow_mut() = true; + if let Some(ui) = state.call_ui.borrow().as_ref() { + call_window::update_screen_visual(ui, true); + } + toast( + state, + &crate::i18n::tf("call_share_failed", &[("error", err)]), + ); + return; + } + if matches!(pending, Some(Pending::ReactMessage)) { + toast( + state, + &crate::i18n::tf("message_reaction_failed", &[("error", err)]), + ); + return; + } + if matches!(pending, Some(Pending::UnsendMessage)) { + toast( + state, + &crate::i18n::tf("message_unsend_failed", &[("error", err)]), + ); + return; + } if err.contains("sticker_not_owned") || err.contains("USER_NOT_STICKER_OWNER") { toast(state, &crate::i18n::t("sticker_not_owned")); } else { @@ -553,15 +709,16 @@ pub(super) fn handle_event(state: &AppState, ev: ProtocolEvent) { } } Some(Pending::FetchMessages { chat_mid }) => { - if state.current_chat.borrow().as_deref() == Some(chat_mid.as_str()) { - let messages: Vec = result - .get("messages") - .cloned() - .and_then(|v| serde_json::from_value(v).ok()) - .unwrap_or_default(); - if !same_message_list(state, &chat_mid, &messages) { - apply_messages(state, messages); - } + let messages: Vec = result + .get("messages") + .cloned() + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + remember_message_snapshot(state, &chat_mid, &messages); + if state.current_chat.borrow().as_deref() == Some(chat_mid.as_str()) + && !same_message_list(state, &chat_mid, &messages) + { + apply_messages(state, messages); } } Some(Pending::Send { @@ -575,6 +732,7 @@ pub(super) fn handle_event(state: &AppState, ev: ProtocolEvent) { if let Ok(msg) = serde_json::from_value::( result.get("message").cloned().unwrap_or_default(), ) { + remember_live_message(state, &chat_mid, &msg); if state.msg_stack.visible_child_name().as_deref() != Some("list") { set_msg_state(state, "list", None); } @@ -606,10 +764,24 @@ pub(super) fn handle_event(state: &AppState, ev: ProtocolEvent) { toast(state, &crate::i18n::t("media_download_failed")); } } + Some(Pending::ProfileLookup(ui)) => { + apply_profile_relation(state, &ui, &result, false); + } + Some(Pending::ProfileAddFriend(ui)) => { + apply_profile_relation(state, &ui, &result, true); + } + Some(Pending::ReactMessage) => {} + Some(Pending::UnsendMessage) => { + toast(state, &crate::i18n::t("message_unsent")); + } + Some(Pending::CallScreenStart | Pending::CallScreenStop) => {} None => {} } } - ProtocolEvent::Error(e) => toast(state, &e), + ProtocolEvent::Error(e) => { + tracing::error!(error = %e, "protocol error"); + toast(state, &e); + } ProtocolEvent::Exited(code) => { if !*state.restarting.borrow() { tracing::warn!(code, "protocol engine exited unexpectedly"); @@ -619,6 +791,79 @@ pub(super) fn handle_event(state: &AppState, ev: ProtocolEvent) { } } +fn apply_profile_relation( + state: &AppState, + ui: &ProfilePendingUi, + result: &serde_json::Value, + just_added: bool, +) { + { + let mut target = ui.target.borrow_mut(); + if let Some(mid) = result.get("mid").and_then(|value| value.as_str()) { + target.mid = mid.to_string(); + } + if let Some(name) = result + .get("displayName") + .and_then(|value| value.as_str()) + .filter(|name| !name.is_empty()) + { + target.name = name.to_string(); + ui.name_label.set_text(name); + } + if let Some(path) = result + .get("avatarPath") + .and_then(|value| value.as_str()) + .filter(|path| std::path::Path::new(path).exists()) + { + target.avatar_path = Some(path.to_string()); + attach_avatar_texture_async(ui.avatar.clone(), path.to_string(), 96); + } + } + let bio = result + .get("statusMessage") + .and_then(|value| value.as_str()) + .unwrap_or(""); + ui.bio_label.set_text(bio); + ui.bio_label.set_visible(!bio.is_empty()); + + let is_friend = just_added + || result + .get("isFriend") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let blocked = result + .get("blocked") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let can_add = !just_added + && result + .get("canAdd") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let can_chat = is_friend + && result + .get("canChat") + .and_then(|value| value.as_bool()) + .unwrap_or(is_friend); + + ui.add_btn.set_visible(!is_friend); + ui.add_btn.set_sensitive(can_add); + ui.chat_btn.set_sensitive(can_chat); + let status = if just_added { + crate::i18n::t("profile_add_success") + } else if is_friend { + crate::i18n::t("profile_friend") + } else if blocked { + crate::i18n::t("profile_blocked") + } else { + crate::i18n::t("profile_not_friend") + }; + ui.status.set_text(&status); + if just_added { + toast(state, &crate::i18n::t("profile_add_success")); + } +} + const MAX_SIDECAR_RECOVERY_ATTEMPTS: u8 = 3; pub(super) fn schedule_sidecar_recovery(state: &AppState) { diff --git a/src/ui/friends.rs b/src/ui/friends.rs index 6ebfa4b..77e530d 100644 --- a/src/ui/friends.rs +++ b/src/ui/friends.rs @@ -1,3 +1,4 @@ +use super::media::attach_avatar_texture_async; use crate::i18n; use crate::protocol::ChatInfo; use crate::sidecar::Sidecar; @@ -293,18 +294,18 @@ fn append_friend_row(ui: &FriendsUi, friend: &ChatInfo) { .valign(gtk::Align::Center) .css_classes(["line-avatar-frame", "line-avatar-sm"]) .build(); + avatar_frame.set_overflow(gtk::Overflow::Hidden); let avatar = gtk::Picture::builder() .content_fit(gtk::ContentFit::Cover) .width_request(40) .height_request(40) .css_classes(["line-avatar", "line-avatar-sm"]) .build(); + avatar.set_overflow(gtk::Overflow::Hidden); if let Some(path) = friend.avatar_path.as_deref() && std::path::Path::new(path).exists() - && let Ok(pixbuf) = gdk_pixbuf::Pixbuf::from_file_at_scale(path, 80, 80, true) { - let tex = gtk::gdk::Texture::for_pixbuf(&pixbuf); - avatar.set_paintable(Some(&tex)); + attach_avatar_texture_async(avatar.clone(), path.to_string(), 40); } avatar_frame.append(&avatar); ui.avatars.borrow_mut().insert(friend.mid.clone(), avatar); diff --git a/src/ui/media.rs b/src/ui/media.rs index 0bb4a91..14151f2 100644 --- a/src/ui/media.rs +++ b/src/ui/media.rs @@ -30,9 +30,9 @@ pub(super) fn image_looks_low_res(path: &str) -> bool { if is_thumb_media_path(path) { return true; } - match gdk_pixbuf::Pixbuf::from_file(path) { - Ok(pb) => pb.width() <= 512 && pb.height() <= 512, - Err(_) => false, + match gdk_pixbuf::Pixbuf::file_info(path) { + Some((_, width, height)) => width <= 512 && height <= 512, + None => false, } } @@ -275,6 +275,243 @@ pub(super) fn wire_media_open_click( wire_media_open_click_widget(state, pic.upcast_ref::(), msg); } +#[derive(Clone)] +struct ViewerMediaItem { + path: String, + content_type: String, + name: String, +} + +fn normalized_media_path(path: &str) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| PathBuf::from(path)) +} + +fn image_gallery_items( + state: &AppState, + current: ViewerMediaItem, +) -> (Vec, usize) { + let mut messages: Vec = state + .media_msgs + .borrow() + .values() + .filter(|message| message.content_type.eq_ignore_ascii_case("image")) + .cloned() + .collect(); + messages.sort_by_key(|message| message.created_time); + + let mut items = Vec::::new(); + let mut positions = HashMap::::new(); + let mut current_index = None; + let current_path = normalized_media_path(¤t.path); + for message in messages { + let Some(path) = local_media_path(&message, true) else { + continue; + }; + let normalized = normalized_media_path(&path); + if let Some(index) = positions.get(&normalized).copied() { + if normalized == current_path { + current_index = Some(index); + } + continue; + } + let index = items.len(); + positions.insert(normalized.clone(), index); + if normalized == current_path { + current_index = Some(index); + } + let name = suggest_media_name(&message); + items.push(ViewerMediaItem { + path, + content_type: message.content_type, + name, + }); + } + + let index = current_index.unwrap_or_else(|| { + let index = items.len(); + items.push(current); + index + }); + (items, index) +} + +fn adjacent_image_index(current: usize, length: usize, delta: isize) -> Option { + let next = current.checked_add_signed(delta)?; + (next < length).then_some(next) +} + +fn clear_viewer_box(box_: >k::Box) { + while let Some(child) = box_.first_child() { + box_.remove(&child); + } +} + +#[allow(clippy::too_many_arguments)] +fn render_image_gallery_item( + state: &AppState, + win: >k::Window, + tools: >k::Box, + body: >k::Box, + title: >k::Label, + previous: >k::Button, + next: >k::Button, + items: &[ViewerMediaItem], + index: usize, + current_item: &Rc>, +) { + let Some(item) = items.get(index).cloned() else { + return; + }; + clear_viewer_box(tools); + clear_viewer_box(body); + if items.len() > 1 { + title.set_label(&format!("{} · {}/{}", item.name, index + 1, items.len())); + } else { + title.set_label(&item.name); + } + previous.set_sensitive(index > 0); + next.set_sensitive(index + 1 < items.len()); + *current_item.borrow_mut() = item.clone(); + append_image_viewer(state, tools, body, &item.path, win); +} + +#[allow(clippy::too_many_arguments)] +fn install_image_gallery( + state: &AppState, + win: >k::Window, + tools: >k::Box, + body: >k::Box, + title: >k::Label, + previous: >k::Button, + next: >k::Button, + current_item: Rc>, +) { + let (items, initial_index) = image_gallery_items(state, current_item.borrow().clone()); + let items = Rc::new(items); + let index = Rc::new(std::cell::Cell::new(initial_index)); + let show_navigation = items.len() > 1; + previous.set_visible(show_navigation); + next.set_visible(show_navigation); + render_image_gallery_item( + state, + win, + tools, + body, + title, + previous, + next, + &items, + initial_index, + ¤t_item, + ); + + { + let state = state.clone(); + let win = win.clone(); + let tools = tools.clone(); + let body = body.clone(); + let title = title.clone(); + let previous = previous.clone(); + let next = next.clone(); + let items = items.clone(); + let index = index.clone(); + let current_item = current_item.clone(); + previous.clone().connect_clicked(move |_| { + let Some(target) = adjacent_image_index(index.get(), items.len(), -1) else { + return; + }; + index.set(target); + render_image_gallery_item( + &state, + &win, + &tools, + &body, + &title, + &previous, + &next, + &items, + target, + ¤t_item, + ); + }); + } + { + let state = state.clone(); + let win = win.clone(); + let tools = tools.clone(); + let body = body.clone(); + let title = title.clone(); + let previous = previous.clone(); + let next = next.clone(); + let items = items.clone(); + let index = index.clone(); + let current_item = current_item.clone(); + next.clone().connect_clicked(move |_| { + let Some(target) = adjacent_image_index(index.get(), items.len(), 1) else { + return; + }; + index.set(target); + render_image_gallery_item( + &state, + &win, + &tools, + &body, + &title, + &previous, + &next, + &items, + target, + ¤t_item, + ); + }); + } + + let controller = gtk::EventControllerKey::new(); + { + let state = state.clone(); + let win = win.clone(); + let tools = tools.clone(); + let body = body.clone(); + let title = title.clone(); + let previous = previous.clone(); + let next = next.clone(); + let items = items.clone(); + let index = index.clone(); + controller.connect_key_pressed(move |_, key, _, modifiers| { + if modifiers.intersects( + gdk::ModifierType::CONTROL_MASK + | gdk::ModifierType::ALT_MASK + | gdk::ModifierType::SUPER_MASK, + ) { + return glib::Propagation::Proceed; + } + let delta = match key { + gdk::Key::Left | gdk::Key::KP_Left => -1, + gdk::Key::Right | gdk::Key::KP_Right => 1, + _ => return glib::Propagation::Proceed, + }; + let Some(target) = adjacent_image_index(index.get(), items.len(), delta) else { + return glib::Propagation::Stop; + }; + index.set(target); + render_image_gallery_item( + &state, + &win, + &tools, + &body, + &title, + &previous, + &next, + &items, + target, + ¤t_item, + ); + glib::Propagation::Stop + }); + } + win.add_controller(controller); +} + pub(super) fn open_media_viewer( state: &AppState, path: &str, @@ -320,6 +557,22 @@ pub(super) fn open_media_viewer( .ellipsize(gtk::pango::EllipsizeMode::End) .css_classes(["heading"]) .build(); + let previous = gtk::Button::builder() + .icon_name("go-previous-symbolic") + .tooltip_text(crate::i18n::t("media_previous_image")) + .css_classes(["flat", "circular"]) + .visible(false) + .build(); + let next = gtk::Button::builder() + .icon_name("go-next-symbolic") + .tooltip_text(crate::i18n::t("media_next_image")) + .css_classes(["flat", "circular"]) + .visible(false) + .build(); + let image_tools = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .build(); let open_ext = gtk::Button::builder() .icon_name("document-open-symbolic") @@ -336,7 +589,10 @@ pub(super) fn open_media_viewer( .tooltip_text(crate::i18n::t("media_close")) .css_classes(["flat", "circular"]) .build(); + bar.append(&previous); + bar.append(&next); bar.append(&title); + bar.append(&image_tools); let body = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) @@ -347,12 +603,26 @@ pub(super) fn open_media_viewer( .css_classes(["line-media-viewer-body"]) .build(); + let current_item = Rc::new(RefCell::new(ViewerMediaItem { + path: path.to_string(), + content_type: content_type.to_string(), + name: suggest_name.to_string(), + })); match kind { ViewerKind::Video => { append_gpu_video_viewer(&body, path); } ViewerKind::Image => { - append_image_viewer(state, &bar, &body, path, &win); + install_image_gallery( + state, + &win, + &image_tools, + &body, + &title, + &previous, + &next, + current_item.clone(), + ); } ViewerKind::Pdf => { append_pdf_viewer(&body, path, suggest_name); @@ -380,18 +650,17 @@ pub(super) fn open_media_viewer( close.connect_clicked(move |_| w.close()); } { - let path = path.to_string(); + let current_item = current_item.clone(); open_ext.connect_clicked(move |_| { - open_path_externally(&path); + open_path_externally(¤t_item.borrow().path); }); } { let s = state.clone(); - let path = path.to_string(); - let name = suggest_name.to_string(); - let ct = content_type.to_string(); + let current_item = current_item.clone(); dl.connect_clicked(move |_| { - save_media_as(&s, &path, &name, &ct); + let item = current_item.borrow().clone(); + save_media_as(&s, &item.path, &item.name, &item.content_type); }); } @@ -1131,6 +1400,7 @@ pub(super) fn send_local_image_file(state: &AppState, path: &std::path::Path) -> file_path: Some(path_str), duration_ms: None, flex: None, + ..Default::default() }, ); show_upload_progress(state, 0.02, &crate::i18n::t("media_uploading")); @@ -1779,15 +2049,55 @@ pub(super) fn attach_texture_async(picture: gtk::Picture, path: String, max_px: attach_texture_async_anim(picture, path, max_px, false); } +/// Load a center-cropped square at its exact logical size. GtkPicture otherwise +/// keeps the source texture's natural dimensions, so differently sized profile +/// files can make nominally identical avatar rows allocate at different sizes. +pub(super) fn attach_avatar_texture_async(picture: gtk::Picture, path: String, size_px: i32) { + let size_px = size_px.max(1); + picture.set_width_request(size_px); + picture.set_height_request(size_px); + picture.set_hexpand(false); + picture.set_vexpand(false); + picture.set_overflow(gtk::Overflow::Hidden); + + let (tx, rx) = async_channel::bounded::>(1); + std::thread::spawn(move || { + let _ = tx.send_blocking(crate::sticker_anim::load_square(&path, size_px)); + }); + glib::spawn_future_local(async move { + if let Ok(Some(frame)) = rx.recv().await + && let Some(pixbuf) = raw_frame_to_pixbuf(&frame) + { + picture.set_paintable(Some(&gdk::Texture::for_pixbuf(&pixbuf))); + } + }); +} + pub(super) fn attach_texture_async_anim( picture: gtk::Picture, path: String, max_px: i32, animate: bool, ) { + let fixed_width = picture.width_request(); + let fixed_height = picture.height_request(); + let has_fixed_canvas = fixed_width > 0 && fixed_height > 0; + let cover = picture.content_fit() == gtk::ContentFit::Cover; + if has_fixed_canvas { + picture.set_hexpand(false); + picture.set_vexpand(false); + picture.set_halign(gtk::Align::Center); + picture.set_valign(gtk::Align::Center); + picture.set_overflow(gtk::Overflow::Hidden); + } let (tx, rx) = async_channel::bounded::>(1); std::thread::spawn(move || { - let _ = tx.send_blocking(crate::sticker_anim::load_scaled(&path, max_px, animate)); + let frames = if has_fixed_canvas { + crate::sticker_anim::load_fitted(&path, fixed_width, fixed_height, cover, animate) + } else { + crate::sticker_anim::load_scaled(&path, max_px, animate) + }; + let _ = tx.send_blocking(frames); }); glib::spawn_future_local(async move { match rx.recv().await { @@ -1920,3 +2230,17 @@ pub(super) fn pump_media_queue(state: &AppState) { }); }); } + +#[cfg(test)] +mod navigation_tests { + use super::adjacent_image_index; + + #[test] + fn image_navigation_stops_at_gallery_edges() { + assert_eq!(adjacent_image_index(1, 3, -1), Some(0)); + assert_eq!(adjacent_image_index(1, 3, 1), Some(2)); + assert_eq!(adjacent_image_index(0, 3, -1), None); + assert_eq!(adjacent_image_index(2, 3, 1), None); + assert_eq!(adjacent_image_index(0, 0, 1), None); + } +} diff --git a/src/ui/messages.rs b/src/ui/messages.rs index 8e705d6..ee997bf 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -53,9 +53,15 @@ pub(super) fn apply_messages(state: &AppState, mut messages: Vec) { } let chat_mid = state.current_chat.borrow().clone(); + let render_gen = *state.message_render_gen.borrow(); let state_c = state.clone(); let batch = Rc::new(RefCell::new(messages)); glib::idle_add_local(move || { + if state_c.current_chat.borrow().as_ref() != chat_mid.as_ref() + || *state_c.message_render_gen.borrow() != render_gen + { + return glib::ControlFlow::Break; + } let mut left = batch.borrow_mut(); let take = left.len().min(14); if take == 0 { @@ -181,7 +187,8 @@ pub(super) fn append_message(state: &AppState, msg: &MessageInfo, live: bool) { let waiting_visual = (msg.content_type.eq_ignore_ascii_case("image") || msg.content_type.eq_ignore_ascii_case("sticker") || msg.content_type.eq_ignore_ascii_case("video")) - && !has_image; + && !has_image + && !msg.media_failed; let is_audio = msg.content_type.eq_ignore_ascii_case("audio"); let is_file = msg.content_type.eq_ignore_ascii_case("file"); let is_video = msg.content_type.eq_ignore_ascii_case("video"); @@ -255,6 +262,19 @@ pub(super) fn append_message(state: &AppState, msg: &MessageInfo, live: bool) { bubble.append(&pic); } } + } else if msg.media_failed && (is_image || is_video || is_sticker) { + if is_video { + append_video_placeholder(state, &bubble, msg, true); + } else { + bubble.append( + >k::Label::builder() + .label(crate::i18n::t("media_download_failed")) + .xalign(0.0) + .wrap(true) + .css_classes(["dim-label", "line-media-failed"]) + .build(), + ); + } } else if waiting_visual { if is_video { append_video_placeholder(state, &bubble, msg, false); @@ -272,6 +292,9 @@ pub(super) fn append_message(state: &AppState, msg: &MessageInfo, live: bool) { } } + append_message_reactions(state, &bubble, msg); + wire_message_actions(state, &bubble, msg); + if !msg.id.is_empty() && message_tracks_media(msg) { state .media_slots @@ -335,14 +358,97 @@ pub(super) fn append_message(state: &AppState, msg: &MessageInfo, live: bool) { outer.append(>k::Box::builder().hexpand(true).build()); outer.append(&col); } else { - // Incoming: bubble + time on the right (mobile style). + // Incoming: sender profile + name + bubble/time (mobile group/OpenChat style). + let sender_col = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(3) + .halign(gtk::Align::Start) + .build(); + if !msg.sender_name.is_empty() { + let sender_name = gtk::Label::builder() + .label(&msg.sender_name) + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::End) + .max_width_chars(34) + .css_classes(["line-msg-sender-name"]) + .build(); + sender_col.append(&sender_name); + } + let bubble_line = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(4) + .valign(gtk::Align::End) + .build(); let time = gtk::Label::builder() .label(&time_txt) .valign(gtk::Align::End) .css_classes(["line-msg-time"]) .build(); - outer.append(&bubble); - outer.append(&time); + bubble_line.append(&bubble); + bubble_line.append(&time); + sender_col.append(&bubble_line); + + // Keep the sender avatar in its own non-expanding slot. GtkButton's + // width/height requests are minimums, so an intrinsically large image + // could make the whole button grow on media-message rows. + let avatar_slot = gtk::Overlay::builder() + .width_request(38) + .height_request(38) + .hexpand(false) + .vexpand(false) + .halign(gtk::Align::Start) + .valign(gtk::Align::Start) + .tooltip_text(if msg.sender_name.is_empty() { + msg.from.clone() + } else { + msg.sender_name.clone() + }) + .css_classes(["line-msg-avatar-slot"]) + .build(); + avatar_slot.set_overflow(gtk::Overflow::Hidden); + avatar_slot.set_cursor_from_name(Some("pointer")); + if let Some(path) = msg + .sender_avatar_path + .as_deref() + .filter(|path| std::path::Path::new(path).exists()) + { + let avatar = gtk::Picture::builder() + .content_fit(gtk::ContentFit::Cover) + .can_shrink(true) + .width_request(34) + .height_request(34) + .hexpand(false) + .vexpand(false) + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .css_classes(["line-msg-avatar"]) + .build(); + avatar.set_overflow(gtk::Overflow::Hidden); + attach_avatar_texture_async(avatar.clone(), path.to_string(), 34); + avatar_slot.set_child(Some(&avatar)); + } else { + avatar_slot.set_child(Some( + >k::Image::builder() + .icon_name("avatar-default-symbolic") + .pixel_size(24) + .hexpand(false) + .vexpand(false) + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .build(), + )); + } + { + let state = state.clone(); + let profile = msg.clone(); + let click = gtk::GestureClick::new(); + click.connect_released(move |_, _, _, _| { + open_sender_profile(&state, &profile); + }); + avatar_slot.add_controller(click); + } + outer.append(&avatar_slot); + outer.append(&sender_col); outer.append(>k::Box::builder().hexpand(true).build()); } @@ -368,6 +474,378 @@ pub(super) fn append_message(state: &AppState, msg: &MessageInfo, live: bool) { list.append(&row); } +fn reaction_choices() -> [(&'static str, &'static str); 6] { + [ + ("NICE", "👍"), + ("LOVE", "❤️"), + ("FUN", "😂"), + ("AMAZING", "😮"), + ("SAD", "😢"), + ("OMG", "😱"), + ] +} + +fn reaction_emoji(kind: &str) -> &'static str { + reaction_choices() + .into_iter() + .find_map(|(candidate, emoji)| (candidate == kind).then_some(emoji)) + .unwrap_or("🙂") +} + +fn request_message_reaction(state: &AppState, chat_mid: &str, message_id: &str, reaction: &str) { + match state.sidecar.react_message(chat_mid, message_id, reaction) { + Ok(id) => { + state.pending.borrow_mut().insert(id, Pending::ReactMessage); + } + Err(error) => toast( + state, + &crate::i18n::tf("message_reaction_failed", &[("error", &error.to_string())]), + ), + } +} + +fn append_message_reactions(state: &AppState, bubble: >k::Box, msg: &MessageInfo) { + let visible: Vec<_> = msg + .reactions + .iter() + .filter(|reaction| reaction.count > 0) + .collect(); + if visible.is_empty() { + return; + } + let bar = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(4) + .css_classes(["line-message-reactions"]) + .build(); + for reaction in visible { + let button = gtk::Button::builder() + .label(format!( + "{} {}", + reaction_emoji(&reaction.kind), + reaction.count + )) + .tooltip_text(crate::i18n::t("message_react")) + .css_classes(["flat", "line-message-reaction-chip"]) + .build(); + if reaction.mine { + button.add_css_class("line-message-reaction-mine"); + } + let state = state.clone(); + let chat_mid = msg.chat_mid.clone(); + let message_id = msg.id.clone(); + let kind = if reaction.mine { + "UNDO".to_string() + } else { + reaction.kind.clone() + }; + button.connect_clicked(move |_| { + request_message_reaction(&state, &chat_mid, &message_id, &kind); + }); + bar.append(&button); + } + bubble.append(&bar); +} + +fn request_message_unsend(state: &AppState, msg: &MessageInfo) { + let dialog = libadwaita::AlertDialog::new( + Some(&crate::i18n::t("message_unsend_title")), + Some(&crate::i18n::t("message_unsend_body")), + ); + dialog.add_response("cancel", &crate::i18n::t("cancel")); + dialog.add_response("unsend", &crate::i18n::t("message_unsend")); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + dialog.set_response_appearance("unsend", libadwaita::ResponseAppearance::Destructive); + let parent = state.window.clone(); + let state = state.clone(); + let chat_mid = msg.chat_mid.clone(); + let message_id = msg.id.clone(); + dialog.connect_response(None, move |_dialog, response| { + if response != "unsend" { + return; + } + match state.sidecar.unsend_message(&chat_mid, &message_id) { + Ok(id) => { + state + .pending + .borrow_mut() + .insert(id, Pending::UnsendMessage); + } + Err(error) => toast( + &state, + &crate::i18n::tf("message_unsend_failed", &[("error", &error.to_string())]), + ), + } + }); + dialog.present(Some(&parent)); +} + +fn wire_message_actions(state: &AppState, bubble: >k::Box, msg: &MessageInfo) { + if msg.id.is_empty() || msg.id.starts_with("pending-") { + return; + } + let popover = gtk::Popover::builder() + .has_arrow(true) + .autohide(true) + .css_classes(["line-message-actions-popover"]) + .build(); + let root = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(6) + .margin_top(8) + .margin_bottom(8) + .margin_start(8) + .margin_end(8) + .build(); + root.append( + >k::Label::builder() + .label(crate::i18n::t("message_react")) + .xalign(0.0) + .css_classes(["caption", "dim-label"]) + .build(), + ); + let reactions = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(2) + .css_classes(["line-message-reaction-picker"]) + .build(); + for (kind, emoji) in reaction_choices() { + let button = gtk::Button::builder() + .label(emoji) + .tooltip_text(format!("{} · {kind}", crate::i18n::t("message_react"))) + .css_classes(["flat", "circular", "line-message-reaction-choice"]) + .build(); + let state = state.clone(); + let chat_mid = msg.chat_mid.clone(); + let message_id = msg.id.clone(); + let popover = popover.clone(); + button.connect_clicked(move |_| { + popover.popdown(); + request_message_reaction(&state, &chat_mid, &message_id, kind); + }); + reactions.append(&button); + } + root.append(&reactions); + if msg.mine { + root.append(>k::Separator::new(gtk::Orientation::Horizontal)); + let unsend = gtk::Button::builder() + .label(crate::i18n::t("message_unsend")) + .halign(gtk::Align::Fill) + .css_classes(["flat", "destructive-action", "line-message-unsend"]) + .build(); + let state = state.clone(); + let msg = msg.clone(); + let popover = popover.clone(); + unsend.connect_clicked(move |_| { + popover.popdown(); + request_message_unsend(&state, &msg); + }); + root.append(&unsend); + } + popover.set_child(Some(&root)); + popover.set_parent(bubble); + + let click = gtk::GestureClick::new(); + click.set_button(gdk::BUTTON_SECONDARY); + let popover_c = popover.clone(); + click.connect_pressed(move |gesture, _presses, x, y| { + popover_c.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1))); + popover_c.popup(); + gesture.set_state(gtk::EventSequenceState::Claimed); + }); + bubble.add_controller(click); +} + +pub(super) fn open_sender_profile(state: &AppState, msg: &MessageInfo) { + let display_name = if msg.sender_name.is_empty() { + msg.from.clone() + } else { + msg.sender_name.clone() + }; + let win = gtk::Window::builder() + .transient_for(&state.window) + .modal(true) + .resizable(false) + .default_width(360) + .title(&display_name) + .css_classes(["line-sender-profile-window"]) + .build(); + let root = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(12) + .margin_top(24) + .margin_bottom(24) + .margin_start(24) + .margin_end(24) + .halign(gtk::Align::Fill) + .css_classes(["line-sender-profile"]) + .build(); + let avatar = gtk::Picture::builder() + .content_fit(gtk::ContentFit::Cover) + .can_shrink(true) + .width_request(96) + .height_request(96) + .halign(gtk::Align::Center) + .css_classes(["line-sender-profile-avatar"]) + .build(); + avatar.set_overflow(gtk::Overflow::Hidden); + if let Some(path) = msg + .sender_avatar_path + .as_deref() + .filter(|path| std::path::Path::new(path).exists()) + { + attach_avatar_texture_async(avatar.clone(), path.to_string(), 96); + } + root.append(&avatar); + let name_label = gtk::Label::builder() + .label(&display_name) + .wrap(true) + .justify(gtk::Justification::Center) + .css_classes(["title-2", "line-sender-profile-name"]) + .build(); + root.append(&name_label); + let bio_label = gtk::Label::builder() + .label(&msg.sender_status_message) + .wrap(true) + .selectable(true) + .justify(gtk::Justification::Center) + .visible(!msg.sender_status_message.is_empty()) + .css_classes(["dim-label", "line-sender-profile-status"]) + .build(); + root.append(&bio_label); + root.append( + >k::Label::builder() + .label(if msg.sender_kind == "openchat" { + format!("OpenChat member · {}", msg.from) + } else { + format!("LINE · {}", msg.from) + }) + .selectable(true) + .wrap(true) + .justify(gtk::Justification::Center) + .css_classes(["caption", "dim-label"]) + .build(), + ); + + let relation_status = gtk::Label::builder() + .label(crate::i18n::t("profile_checking")) + .wrap(true) + .justify(gtk::Justification::Center) + .css_classes(["caption", "dim-label", "line-profile-relation"]) + .build(); + root.append(&relation_status); + + let actions = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .halign(gtk::Align::Center) + .css_classes(["line-sender-profile-actions"]) + .build(); + let add_btn = gtk::Button::builder() + .label(crate::i18n::t("profile_add_friend")) + .sensitive(false) + .css_classes(["suggested-action"]) + .build(); + let chat_btn = gtk::Button::builder() + .label(crate::i18n::t("profile_private_chat")) + .sensitive(false) + .build(); + actions.append(&add_btn); + actions.append(&chat_btn); + root.append(&actions); + + let target = Rc::new(RefCell::new(ProfileChatTarget { + mid: msg.from.clone(), + name: display_name, + avatar_path: msg.sender_avatar_path.clone(), + })); + let pending_ui = ProfilePendingUi { + target: target.clone(), + avatar: avatar.clone(), + name_label: name_label.clone(), + bio_label: bio_label.clone(), + status: relation_status.clone(), + add_btn: add_btn.clone(), + chat_btn: chat_btn.clone(), + }; + + { + let state = state.clone(); + let pending_ui = pending_ui.clone(); + add_btn.connect_clicked(move |button| { + let mid = pending_ui.target.borrow().mid.clone(); + button.set_sensitive(false); + pending_ui + .status + .set_text(&crate::i18n::t("profile_checking")); + match state.sidecar.add_friend_mid(&mid) { + Ok(id) => { + state + .pending + .borrow_mut() + .insert(id, Pending::ProfileAddFriend(pending_ui.clone())); + } + Err(error) => { + button.set_sensitive(true); + let message = + crate::i18n::tf("profile_add_failed", &[("error", &error.to_string())]); + pending_ui.status.set_text(&message); + toast(&state, &message); + } + } + }); + } + { + let state = state.clone(); + let target = target.clone(); + let win = win.clone(); + chat_btn.connect_clicked(move |_| { + let target = target.borrow().clone(); + let chat = ChatInfo { + mid: target.mid, + name: target.name, + kind: "dm".into(), + avatar_path: target.avatar_path, + last_activity: 0, + unread: 0, + preview: String::new(), + muted: false, + pinned: false, + }; + ensure_chat_visible(&state, &chat); + win.close(); + open_chat(&state, &chat); + }); + } + + win.set_child(Some(&root)); + win.present(); + + let is_openchat = msg.sender_kind == "openchat" || !msg.from.starts_with('u'); + let is_self = state.self_mid.borrow().as_deref() == Some(msg.from.as_str()); + if is_openchat { + relation_status.set_text(&crate::i18n::t("profile_openchat_private_unavailable")); + return; + } + if is_self { + relation_status.set_text(&crate::i18n::t("profile_self")); + return; + } + match state.sidecar.profile_relation(&msg.from) { + Ok(id) => { + state + .pending + .borrow_mut() + .insert(id, Pending::ProfileLookup(pending_ui)); + } + Err(error) => { + relation_status.set_text(&error.to_string()); + toast(state, &error.to_string()); + } + } +} + pub(super) fn format_outgoing_status(read: bool, created_ms: i64) -> String { let t = format_msg_time(created_ms); if read { @@ -378,9 +856,9 @@ pub(super) fn format_outgoing_status(read: bool, created_ms: i64) -> String { format!("✓✓ {} {}", crate::i18n::t("status_read"), t) } } else if t.is_empty() { - "✓".into() + format!("✓ {}", crate::i18n::t("status_sent")) } else { - format!("✓ {t}") + format!("✓ {} {t}", crate::i18n::t("status_sent")) } } @@ -452,12 +930,18 @@ pub(super) fn append_voice_card( let play = gtk::Button::builder() .icon_name(if has_audio { "media-playback-start-symbolic" + } else if msg.media_failed { + "dialog-error-symbolic" } else { "content-loading-symbolic" }) .sensitive(has_audio) .css_classes(["circular", "line-voice-play"]) - .tooltip_text(crate::i18n::t("play_voice")) + .tooltip_text(if msg.media_failed { + crate::i18n::t("media_download_failed") + } else { + crate::i18n::t("play_voice") + }) .build(); let peaks = Rc::new(RefCell::new(placeholder_peaks(36))); @@ -499,6 +983,8 @@ pub(super) fn append_voice_card( format_voice_duration(duration_ms) } else if has_audio { "--:--".into() + } else if msg.media_failed { + crate::i18n::t("media_download_failed") } else { crate::i18n::t("loading_voice") }; @@ -974,6 +1460,7 @@ pub(super) fn attach_audio_to_slot(state: &AppState, message_id: &str, path: &st file_path: Some(path.to_string()), duration_ms, flex: None, + ..Default::default() }; append_voice_card(state, &bubble, &msg, true); } @@ -1003,9 +1490,10 @@ pub(super) fn spawn_audio_player( }; let gain = gain.clamp(0.0, 2.5); let mpv_vol = (gain * 100.0).round().clamp(0.0, 250.0); + let mut failures = Vec::new(); let mut mpv = std::process::Command::new("mpv"); - mpv.args(["--no-video", "--really-quiet"]); + mpv.args(["--no-config", "--no-video", "--really-quiet"]); mpv.arg(format!("--volume={mpv_vol}")); if let Some(ref s) = sink { mpv.arg(format!("--audio-device=pulse/{s}")); @@ -1013,8 +1501,12 @@ pub(super) fn spawn_audio_player( mpv.arg(path); mpv.stdout(std::process::Stdio::null()); mpv.stderr(std::process::Stdio::null()); - if let Ok(child) = mpv.spawn() { - return Ok(child); + match mpv.spawn() { + Ok(child) => match verify_audio_child(child, "mpv") { + Ok(child) => return Ok(child), + Err(error) => failures.push(error), + }, + Err(error) => failures.push(format!("mpv: {error}")), } let mut ff = std::process::Command::new("ffplay"); @@ -1023,13 +1515,19 @@ pub(super) fn spawn_audio_player( ff.args(["-af", &format!("volume={gain}")]); } if let Some(ref s) = sink { - ff.args(["-audiodevice", s]); + // ffplay uses SDL; PULSE_SINK is understood when its Pulse/PipeWire + // backend is active, while `-audiodevice` is not a portable ffplay flag. + ff.env("PULSE_SINK", s); } ff.arg(path); ff.stdout(std::process::Stdio::null()); ff.stderr(std::process::Stdio::null()); - if let Ok(child) = ff.spawn() { - return Ok(child); + match ff.spawn() { + Ok(child) => match verify_audio_child(child, "ffplay") { + Ok(child) => return Ok(child), + Err(error) => failures.push(error), + }, + Err(error) => failures.push(format!("ffplay: {error}")), } if (gain - 1.0).abs() < 0.01 { @@ -1040,12 +1538,39 @@ pub(super) fn spawn_audio_player( paplay.arg(path); paplay.stdout(std::process::Stdio::null()); paplay.stderr(std::process::Stdio::null()); - if let Ok(child) = paplay.spawn() { - return Ok(child); + match paplay.spawn() { + Ok(child) => match verify_audio_child(child, "paplay") { + Ok(child) => return Ok(child), + Err(error) => failures.push(error), + }, + Err(error) => failures.push(format!("paplay: {error}")), } } - Err("no audio player (mpv/ffplay)".into()) + Err(if failures.is_empty() { + "no audio player (mpv/ffplay/paplay)".into() + } else { + failures.join("; ") + }) +} + +fn verify_audio_child( + mut child: std::process::Child, + player: &str, +) -> Result { + // spawn() only confirms that the executable was found. Broken shared + // libraries or an invalid audio backend make it exit immediately; detect + // that and continue to the next player instead of showing fake playback. + std::thread::sleep(std::time::Duration::from_millis(120)); + match child.try_wait() { + Ok(None) => Ok(child), + Ok(Some(status)) => Err(format!("{player} exited immediately ({status})")), + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + Err(format!("{player}: {error}")) + } + } } pub(super) fn filter_chats(state: &AppState, query: &str) { @@ -1208,6 +1733,8 @@ pub(super) fn append_link_chips(_state: &AppState, bubble: >k::Box, text: &str } pub(super) fn clear_messages(state: &AppState) { + let next_render_gen = state.message_render_gen.borrow().wrapping_add(1); + *state.message_render_gen.borrow_mut() = next_render_gen; stop_voice_playback(state); state.message_list.clear(); state.media_slots.borrow_mut().clear(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e1ec930..48f227a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,4 @@ +mod backgrounds; mod call_window; mod calls; mod chats; @@ -19,6 +20,7 @@ use crate::config::{AppConfig, apply_animations, apply_font, apply_theme}; use crate::protocol::{ChatInfo, FlexAction, MessageInfo, Profile, ProtocolEvent}; use crate::sidecar::Sidecar; use anyhow::Result; +use backgrounds::*; use calls::*; use chats::*; use composer_media::*; @@ -154,6 +156,8 @@ fn build_ui(app: &Application, repo_root: PathBuf, data_dir: PathBuf) -> Result< media_msgs: Rc::new(RefCell::new(HashMap::new())), receipt_slots: Rc::new(RefCell::new(HashMap::new())), msg_created: Rc::new(RefCell::new(HashMap::new())), + message_cache: Rc::new(RefCell::new(HashMap::new())), + message_render_gen: Rc::new(RefCell::new(0)), last_msg_day: Rc::new(RefCell::new(None)), seen_msg_ids: Rc::new(RefCell::new(HashSet::new())), last_incoming_id: Rc::new(RefCell::new(None)), @@ -194,6 +198,12 @@ fn build_ui(app: &Application, repo_root: PathBuf, data_dir: PathBuf) -> Result< sticker_popover: shell.sticker_popover, call_btn: shell.call_btn, mute_btn: shell.mute_btn, + pin_btn: shell.pin_btn, + album_btn: shell.album_btn, + background_btn: shell.background_btn, + background_popover: shell.background_popover, + message_background_layer: shell.message_background_layer, + message_background_picture: shell.message_background_picture, composer_stack: shell.composer_stack, record_cancel_btn: shell.record_cancel_btn, record_send_btn: shell.record_send_btn, @@ -213,6 +223,8 @@ fn build_ui(app: &Application, repo_root: PathBuf, data_dir: PathBuf) -> Result< call_ui: Rc::new(RefCell::new(None)), call_mic_muted: Rc::new(RefCell::new(false)), call_deafened: Rc::new(RefCell::new(false)), + call_video_capable: Rc::new(RefCell::new(false)), + call_screen_sharing: Rc::new(RefCell::new(false)), tray: Rc::new(RefCell::new(None)), tray_tx, discord: crate::discord_rpc::DiscordRpc::start(), @@ -222,7 +234,7 @@ fn build_ui(app: &Application, repo_root: PathBuf, data_dir: PathBuf) -> Result< self_avatar_path: Rc::new(RefCell::new(None)), self_picture_url: Rc::new(RefCell::new(None)), msg_list_fp: Rc::new(RefCell::new(None)), - notif_pending: Rc::new(RefCell::new(HashMap::new())), + notified_msg_ids: Rc::new(RefCell::new(HashSet::new())), media_ready_paths: Rc::new(RefCell::new(HashMap::new())), }; @@ -442,7 +454,7 @@ fn apply_self_profile( state.profile_avatar.set_visible(true); if let Some(path) = avatar_path.filter(|p| !p.is_empty() && std::path::Path::new(p).exists()) { *state.self_avatar_path.borrow_mut() = Some(path.to_string()); - attach_texture_async(state.profile_avatar.clone(), path.to_string(), 64); + attach_avatar_texture_async(state.profile_avatar.clone(), path.to_string(), 26); } sync_discord_rpc(state); } @@ -953,6 +965,7 @@ fn send_current(state: &AppState) { file_path: None, duration_ms: None, flex: None, + ..Default::default() }, ); state.composer.set_text(""); @@ -1044,6 +1057,27 @@ fn apply_ui_language(state: &AppState) { state .sticker_btn .set_tooltip_text(Some(&crate::i18n::t("stickers"))); + state + .album_btn + .set_tooltip_text(Some(&crate::i18n::t("chat_album"))); + state + .background_btn + .set_tooltip_text(Some(&crate::i18n::t("chat_background"))); + let pinned = state + .current_chat + .borrow() + .as_ref() + .and_then(|mid| { + state + .chats + .borrow() + .iter() + .find(|chat| &chat.mid == mid) + .cloned() + }) + .map(|chat| chat.pinned) + .unwrap_or(false); + update_pin_btn(state, pinned); state .search_entry .set_placeholder_text(Some(&crate::i18n::t("search"))); @@ -1145,22 +1179,25 @@ fn wire_drop_attachments(state: &AppState) { let s = state.clone(); drop.connect_drop(move |_, value, _x, _y| { if let Ok(list) = value.get::() { - let mut any = false; + let mut paths = Vec::new(); for file in list.files() { if let Some(path) = file.path() && path.is_file() { - send_local_media_path(&s, path); - any = true; + paths.push(path); } } - return any; + if !paths.is_empty() { + open_media_review(&s, paths); + return true; + } + return false; } if let Ok(file) = value.get::() { if let Some(path) = file.path() && path.is_file() { - send_local_media_path(&s, path); + open_media_review(&s, vec![path]); return true; } return false; @@ -1168,7 +1205,7 @@ fn wire_drop_attachments(state: &AppState) { if let Ok(tex) = value.get::() { match save_clipboard_texture_png(&s, &tex) { Ok(path) => { - send_local_media_path(&s, path); + open_media_review(&s, vec![path]); true } Err(e) => { @@ -1211,7 +1248,9 @@ fn wire_actions(state: &AppState) { glib::Propagation::Proceed } }); - state.composer.add_controller(paste); + // Capture at the window so pasting an image works even when the pointer or + // focus is in the message view. Plain text still propagates to the entry. + state.window.add_controller(paste); wire_drop_attachments(state); @@ -1409,6 +1448,15 @@ fn wire_actions(state: &AppState) { state.mute_btn.connect_clicked(move |_| { toggle_chat_mute(&s); }); + let s = state.clone(); + state.pin_btn.connect_clicked(move |_| { + toggle_chat_pin(&s); + }); + let s = state.clone(); + state.album_btn.connect_clicked(move |_| { + open_chat_album(&s); + }); + wire_chat_background_actions(state); } fn open_friends_popup(state: &AppState) { @@ -1527,11 +1575,26 @@ fn open_chat(state: &AppState, chat: &ChatInfo) { let can_mute = chat.mid.starts_with('u'); state.mute_btn.set_sensitive(can_mute); update_mute_btn(state, chat.muted); + state.pin_btn.set_sensitive(true); + update_pin_btn(state, chat.pinned); + state.album_btn.set_sensitive(true); + state + .album_btn + .set_tooltip_text(Some(&crate::i18n::t("chat_album"))); + state.background_btn.set_sensitive(true); + apply_chat_background(state, &chat.mid); state.media_queue.borrow_mut().clear(); - set_msg_state(state, "loading", None); - clear_messages(state); - *state.msg_list_fp.borrow_mut() = None; - match state.sidecar.fetch_messages(&chat.mid, 40) { + let memory_hit = state.message_cache.borrow().get(&chat.mid).cloned(); + if let Some(messages) = memory_hit { + // Show the in-process cache now; the sidecar refresh below merges any + // newer server rows and keeps the durable JSON cache authoritative. + apply_messages(state, messages); + } else { + set_msg_state(state, "loading", None); + clear_messages(state); + *state.msg_list_fp.borrow_mut() = None; + } + match state.sidecar.fetch_messages(&chat.mid, 200) { Ok(id) => { state.pending.borrow_mut().insert( id, diff --git a/src/ui/notifications.rs b/src/ui/notifications.rs index 8fbb91d..2565b3e 100644 --- a/src/ui/notifications.rs +++ b/src/ui/notifications.rs @@ -1,4 +1,4 @@ -use super::{AppState, PendingNotif, notifications_allowed, preview_body_ui}; +use super::{AppState, notifications_allowed, preview_body_ui}; use crate::desktop_notify::ChatNotification; use crate::protocol::MessageInfo; use gtk::gio; @@ -9,6 +9,12 @@ pub(super) fn notify_incoming(state: &AppState, msg: &MessageInfo, peer_mid: &st if !notifications_allowed(state) { return; } + // Polling/recovery can surface the same operation repeatedly. Keep the + // notification id as a one-shot guard instead of asking the desktop daemon + // to replace/re-pop the same banner several times. + if !msg.id.is_empty() && state.notified_msg_ids.borrow().contains(&msg.id) { + return; + } let muted = state .chats .borrow() @@ -34,19 +40,11 @@ pub(super) fn notify_incoming(state: &AppState, msg: &MessageInfo, peer_mid: &st .map(str::to_string); if !msg.id.is_empty() { - let mut pending = state.notif_pending.borrow_mut(); - if pending.len() > 64 { - pending.clear(); + let mut notified = state.notified_msg_ids.borrow_mut(); + if notified.len() >= 1024 { + notified.clear(); } - pending.insert( - msg.id.clone(), - PendingNotif { - chat_mid: peer_mid.to_string(), - title: name.clone(), - body: body.clone(), - avatar_path: avatar_path.clone(), - }, - ); + notified.insert(msg.id.clone()); } send_desktop_notification( @@ -67,41 +65,10 @@ pub(super) fn notify_incoming(state: &AppState, msg: &MessageInfo, peer_mid: &st } } -pub(super) fn refresh_notification_media(state: &AppState, message_id: &str, image_path: &str) { - if !notifications_allowed(state) { - return; - } - let Some(meta) = state.notif_pending.borrow().get(message_id).cloned() else { - return; - }; - let muted = state - .chats - .borrow() - .iter() - .find(|c| c.mid == meta.chat_mid) - .map(|c| c.muted) - .unwrap_or(false); - if muted { - return; - } - let viewing = state.current_chat.borrow().as_deref() == Some(meta.chat_mid.as_str()); - if viewing && state.window.is_active() { - state.notif_pending.borrow_mut().remove(message_id); - return; - } - - send_desktop_notification( - state, - ChatNotification { - title: &meta.title, - body: &meta.body, - avatar_path: meta.avatar_path.as_deref(), - image_path: Some(image_path), - chat_mid: &meta.chat_mid, - message_id, - suppress_sound: true, - }, - ); +pub(super) fn refresh_notification_media(_state: &AppState, _message_id: &str, _image_path: &str) { + // Intentionally do not resend when a photo/sticker finishes downloading. + // Several notification servers present a replacement as a fresh banner, + // which made one LINE message appear two or three times. } fn send_desktop_notification(state: &AppState, request: ChatNotification<'_>) { diff --git a/src/ui/shell.rs b/src/ui/shell.rs index e7fc9b5..e5c9147 100644 --- a/src/ui/shell.rs +++ b/src/ui/shell.rs @@ -44,6 +44,12 @@ pub struct ShellWidgets { pub sticker_btn: gtk::Button, pub call_btn: gtk::Button, pub mute_btn: gtk::Button, + pub pin_btn: gtk::Button, + pub album_btn: gtk::Button, + pub background_btn: gtk::Button, + pub background_popover: gtk::Popover, + pub message_background_layer: gtk::Box, + pub message_background_picture: gtk::Picture, pub jump_banner: gtk::Revealer, pub jump_banner_btn: gtk::Button, pub jump_banner_label: gtk::Label, @@ -91,6 +97,7 @@ pub fn build_shell_page() -> ShellWidgets { .visible(false) .css_classes(["line-title-avatar"]) .build(); + profile_avatar.set_overflow(gtk::Overflow::Hidden); let profile_label = gtk::Label::builder() .label("LINE GTK") .css_classes(["heading", "line-title-name"]) @@ -297,7 +304,35 @@ pub fn build_shell_page() -> ShellWidgets { .css_classes(["flat", "circular"]) .sensitive(false) .build(); + let pin_btn = gtk::Button::builder() + .icon_name("non-starred-symbolic") + .tooltip_text("Pin chat") + .css_classes(["flat", "circular"]) + .sensitive(false) + .build(); + let album_btn = gtk::Button::builder() + .icon_name("folder-pictures-symbolic") + .tooltip_text("Chat album") + .css_classes(["flat", "circular"]) + .sensitive(false) + .build(); + let background_btn = gtk::Button::builder() + .icon_name("preferences-desktop-wallpaper-symbolic") + .tooltip_text(crate::i18n::t("chat_background")) + .css_classes(["flat", "circular"]) + .sensitive(false) + .build(); + let background_popover = gtk::Popover::builder() + .autohide(true) + .has_arrow(true) + .position(gtk::PositionType::Bottom) + .css_classes(["line-chat-background-popover"]) + .build(); + background_popover.set_parent(&background_btn); chat_bar.append(&title_col); + chat_bar.append(&background_btn); + chat_bar.append(&album_btn); + chat_bar.append(&pin_btn); chat_bar.append(&mute_btn); chat_bar.append(&call_btn); @@ -350,6 +385,31 @@ pub fn build_shell_page() -> ShellWidgets { msg_stack.add_named(&msg_empty, Some("empty")); msg_stack.set_visible_child_name("idle"); + let message_background_layer = gtk::Box::builder() + .hexpand(true) + .vexpand(true) + .css_classes(["line-chat-background-layer", "line-chat-bg-default"]) + .build(); + let message_background_picture = gtk::Picture::builder() + .content_fit(gtk::ContentFit::Cover) + .can_shrink(true) + .hexpand(true) + .vexpand(true) + .visible(false) + .opacity(0.34) + .can_target(false) + .css_classes(["line-chat-background-picture"]) + .build(); + message_background_picture.set_overflow(gtk::Overflow::Hidden); + let message_stage = gtk::Overlay::builder() + .child(&message_background_layer) + .hexpand(true) + .vexpand(true) + .css_classes(["line-message-stage"]) + .build(); + message_stage.add_overlay(&message_background_picture); + message_stage.add_overlay(&msg_stack); + let jump_banner = gtk::Revealer::builder() .transition_type(gtk::RevealerTransitionType::SlideUp) .transition_duration(180) @@ -519,7 +579,7 @@ pub fn build_shell_page() -> ShellWidgets { sticker_popover.set_parent(&sticker_btn); conversation.append(&chat_bar); - conversation.append(&msg_stack); + conversation.append(&message_stage); conversation.append(&jump_banner); conversation.append(&upload_revealer); conversation.append(&composer_stack); @@ -587,6 +647,12 @@ pub fn build_shell_page() -> ShellWidgets { sticker_btn, call_btn, mute_btn, + pin_btn, + album_btn, + background_btn, + background_popover, + message_background_layer, + message_background_picture, jump_banner, jump_banner_btn, jump_banner_label, @@ -605,13 +671,13 @@ pub fn load_css() { background: @window_bg_color; } .line-app-brand { - margin-start: 4px; + margin-left: 4px; } .line-app-brand-label { font-weight: 600; } .line-app-brand-icon { - margin-end: 0; + margin-right: 0; } .line-sidebar { background: alpha(@window_bg_color, 0.96); @@ -664,6 +730,93 @@ pub fn load_css() { ), @window_bg_color; } + .line-message-stage, + .line-chat-background-layer { + background: @window_bg_color; + } + .line-chat-bg-default { + background: + linear-gradient( + 180deg, + alpha(@accent_bg_color, 0.045) 0%, + transparent 140px + ), + @window_bg_color; + } + .line-chat-bg-mint { + background: + linear-gradient( + 145deg, + alpha(@success_bg_color, 0.16), + alpha(@accent_bg_color, 0.055) + ), + @window_bg_color; + } + .line-chat-bg-ocean { + background: + linear-gradient( + 155deg, + alpha(@accent_bg_color, 0.18), + alpha(@window_bg_color, 0.96) 64% + ), + @window_bg_color; + } + .line-chat-bg-dusk { + background: + linear-gradient( + 145deg, + alpha(@warning_bg_color, 0.13), + alpha(@accent_bg_color, 0.09) + ), + @window_bg_color; + } + .line-chat-bg-graphite { + background: + linear-gradient( + 145deg, + alpha(@window_fg_color, 0.10), + alpha(@window_fg_color, 0.035) + ), + @window_bg_color; + } + .line-chat-background-picture { + background: transparent; + } + .line-chat-background-menu { + min-width: 320px; + } + .line-chat-bg-choice { + min-height: 44px; + padding: 6px 8px; + border-radius: 10px; + } + .line-chat-bg-choice-selected { + background: alpha(@accent_bg_color, 0.16); + } + .line-chat-bg-swatch { + min-width: 36px; + min-height: 28px; + border-radius: 8px; + border: 1px solid alpha(@borders, 0.55); + } + .line-bg-swatch-default { + background: @window_bg_color; + } + .line-bg-swatch-mint { + background: alpha(@success_bg_color, 0.32); + } + .line-bg-swatch-ocean { + background: alpha(@accent_bg_color, 0.34); + } + .line-bg-swatch-dusk { + background: alpha(@warning_bg_color, 0.34); + } + .line-bg-swatch-graphite { + background: alpha(@window_fg_color, 0.18); + } + .line-chat-background-custom { + min-height: 40px; + } .line-chat-bar { background: transparent; border-bottom: 1px solid alpha(@borders, 0.35); @@ -691,7 +844,6 @@ pub fn load_css() { } .line-title-avatar { border-radius: 999px; - overflow: hidden; min-width: 26px; min-height: 26px; background: alpha(@borders, 0.35); @@ -701,7 +853,6 @@ pub fn load_css() { } .line-avatar-frame { border-radius: 999px; - overflow: hidden; min-width: 48px; min-height: 48px; background: alpha(@borders, 0.35); @@ -728,7 +879,6 @@ pub fn load_css() { } .line-call-avatar { border-radius: 999px; - overflow: hidden; min-width: 96px; min-height: 96px; background: alpha(@accent_bg_color, 0.35); @@ -748,7 +898,6 @@ pub fn load_css() { } .line-voice-card { min-width: 180px; - max-width: min(340px, 78%); padding: 2px 0; } .line-voice-play { @@ -811,6 +960,44 @@ pub fn load_css() { .line-media-viewer-root { background: @window_bg_color; } + .line-media-review-window, + .line-media-review { + background: @window_bg_color; + } + .line-media-review-grid { + padding: 16px; + background: @view_bg_color; + } + .line-media-review-card { + padding: 8px; + border-radius: 12px; + background: alpha(@card_bg_color, 0.96); + border: 1px solid alpha(@borders, 0.45); + } + .line-media-review-preview { + border-radius: 9px; + background: alpha(@borders, 0.20); + } + .line-media-review-thumb { + border-radius: 9px; + } + .line-media-review-file-icon { + padding: 32px; + } + .line-media-review-remove { + min-width: 30px; + min-height: 30px; + margin: 6px; + color: @window_bg_color; + background: alpha(@window_fg_color, 0.76); + box-shadow: 0 1px 3px alpha(@window_fg_color, 0.28); + } + .line-media-review-remove:hover { + background: alpha(@window_fg_color, 0.92); + } + .line-media-review-footer { + border-top: 1px solid alpha(@borders, 0.38); + } .line-media-viewer-bar { background: alpha(@headerbar_bg_color, 0.96); border-bottom: 1px solid alpha(@borders, 0.45); @@ -899,10 +1086,20 @@ pub fn load_css() { min-height: 42px; } .line-chat-name { font-weight: 650; } + .line-chat-pinned { color: @accent_color; opacity: 0.9; } .line-chat-preview { opacity: 0.72; font-size: 0.85em; } + .line-album-thumb-btn { + padding: 0; + border-radius: 14px; + } + .line-album-thumb { + min-width: 160px; + min-height: 160px; + border-radius: 14px; + background: alpha(@borders, 0.22); + } .line-bubble-image { border-radius: 12px; - max-width: min(420px, 72%); } .line-bubble-sticker { background: transparent; @@ -911,7 +1108,6 @@ pub fn load_css() { box-shadow: none; padding: 0; margin: 0; - max-width: min(160px, 56%); } .line-bubble-sticker .line-bubble-image, .line-sticker-image { @@ -920,7 +1116,6 @@ pub fn load_css() { border: none; box-shadow: none; border-radius: 0; - max-width: 160px; } .line-flex-card { border-radius: 12px; @@ -928,7 +1123,6 @@ pub fn load_css() { background: alpha(@card_bg_color, 0.98); border: 1px solid alpha(@borders, 0.55); min-width: 180px; - max-width: min(420px, 72%); } .line-flex-title { font-weight: 700; } .line-flex-action { margin-top: 4px; } @@ -961,7 +1155,6 @@ pub fn load_css() { border-radius: 16px; padding: 8px 12px; box-shadow: 0 1px 2px alpha(@window_fg_color, 0.08); - max-width: min(480px, 72%); } .line-bubble-in { background: alpha(@card_bg_color, 0.97); @@ -988,6 +1181,63 @@ pub fn load_css() { margin-left: 4px; margin-right: 4px; } + .line-message-reactions { + margin-top: 2px; + } + .line-message-reaction-chip { + min-height: 24px; + padding: 1px 7px; + border-radius: 999px; + font-size: 0.78em; + background: alpha(@card_bg_color, 0.78); + border: 1px solid alpha(@borders, 0.42); + } + .line-message-reaction-mine { + color: @accent_color; + background: alpha(@accent_bg_color, 0.18); + border-color: alpha(@accent_bg_color, 0.48); + } + .line-message-reaction-choice { + min-width: 38px; + min-height: 38px; + padding: 2px; + font-size: 1.2em; + } + .line-message-reaction-choice:hover { + background: alpha(@accent_bg_color, 0.16); + } + .line-message-unsend { + color: @destructive_color; + } + .line-msg-sender-name { + font-size: 0.78em; + font-weight: 650; + opacity: 0.78; + margin-left: 6px; + } + .line-msg-avatar-slot { + min-width: 38px; + min-height: 38px; + margin-top: 18px; + border-radius: 999px; + } + .line-msg-avatar, + .line-sender-profile-avatar { + border-radius: 999px; + background: alpha(@borders, 0.25); + } + .line-msg-avatar-slot:hover { + background: alpha(@accent_bg_color, 0.16); + } + .line-sender-profile { + min-width: 280px; + } + .line-sender-profile-name { + font-weight: 750; + } + .line-sender-profile-status { + padding: 4px 12px; + } .line-day-sep-row { margin-top: 12px; margin-bottom: 8px; @@ -1084,7 +1334,8 @@ pub fn load_css() { padding: 0 14px; } .line-composer-narrow { - spacing: 4px; + padding-left: 2px; + padding-right: 2px; } .line-composer-narrow .line-send-btn { min-width: 34px; diff --git a/src/ui/state.rs b/src/ui/state.rs index fd2228e..bf7cbc8 100644 --- a/src/ui/state.rs +++ b/src/ui/state.rs @@ -65,6 +65,12 @@ pub(super) struct AppState { pub media_msgs: Rc>>, pub receipt_slots: Rc>>, pub msg_created: Rc>>, + /// Fully hydrated message rows retained while the app is running. The + /// protocol sidecar remains the durable disk cache; this layer makes + /// switching back to an already-opened chat immediate. + pub message_cache: Rc>>>, + /// Cancels idle render jobs when a thread is rebuilt or another chat opens. + pub message_render_gen: Rc>, pub last_msg_day: Rc>>, pub seen_msg_ids: Rc>>, pub last_incoming_id: Rc>>, @@ -105,6 +111,12 @@ pub(super) struct AppState { pub sticker_popover: gtk::Popover, pub call_btn: gtk::Button, pub mute_btn: gtk::Button, + pub pin_btn: gtk::Button, + pub album_btn: gtk::Button, + pub background_btn: gtk::Button, + pub background_popover: gtk::Popover, + pub message_background_layer: gtk::Box, + pub message_background_picture: gtk::Picture, pub composer_stack: gtk::Stack, pub record_cancel_btn: gtk::Button, pub record_send_btn: gtk::Button, @@ -124,6 +136,8 @@ pub(super) struct AppState { pub call_ui: Rc>>, pub call_mic_muted: Rc>, pub call_deafened: Rc>, + pub call_video_capable: Rc>, + pub call_screen_sharing: Rc>, pub tray: Rc>>, pub tray_tx: async_channel::Sender, pub discord: crate::discord_rpc::DiscordRpc, @@ -133,18 +147,28 @@ pub(super) struct AppState { pub self_avatar_path: Rc>>, pub self_picture_url: Rc>>, pub msg_list_fp: Rc>>, - pub notif_pending: Rc>>, + pub notified_msg_ids: Rc>>, pub media_ready_paths: Rc>>, } #[derive(Debug, Clone)] -pub(super) struct PendingNotif { - pub chat_mid: String, - pub title: String, - pub body: String, +pub(super) struct ProfileChatTarget { + pub mid: String, + pub name: String, pub avatar_path: Option, } +#[derive(Debug, Clone)] +pub(super) struct ProfilePendingUi { + pub target: Rc>, + pub avatar: gtk::Picture, + pub name_label: gtk::Label, + pub bio_label: gtk::Label, + pub status: gtk::Label, + pub add_btn: gtk::Button, + pub chat_btn: gtk::Button, +} + #[derive(Debug, Clone)] pub(super) enum Pending { Login, @@ -164,4 +188,10 @@ pub(super) enum Pending { content_type: String, suggest_name: String, }, + ProfileLookup(ProfilePendingUi), + ProfileAddFriend(ProfilePendingUi), + ReactMessage, + UnsendMessage, + CallScreenStart, + CallScreenStop, }