Skip to content
Clone

feat(web/app): add the API adapter, storage and voice services

Wrap the generated client behind one adapter that translates the legacy
{pathParams, params, data} call shape, keeps operation-id aliases for the
renamed space endpoints and pins the response envelope. Storage and voice keep
their client-side state (cache ownership, LiveKit session) out of the domains.
ZhenYi committed
parent d3d60a5 commit 93a2be6 Browse files

12 files changed

+2050 −0

File tree 12
Added view/app/src/services/api/index.ts View view/app/src/services/api/index.ts
@@ -0,0 +1,227 @@
1import { OPS } from './ops'
2
3export interface ApiCallConfig {
4 pathParams?: Record<string, string | number>
5 params?: Record<string, unknown>
6 data?: unknown
7 headers?: Record<string, string>
8 signal?: AbortSignal
9}
10
11interface RawResponse {
12 status: number
13 data: unknown
14 headers?: Headers
15}
16
17export interface ApiEnvelopeShape<T = unknown> {
18 code: number
19 message: string
20 data?: T | null
21 error?: string | null
22 error_code?: string | null
23 request_id?: string
24}
25
26function text(value: unknown): string {
27 if (typeof value === 'string') return value
28 if (value && typeof value === 'object') {
29 const body = value as Record<string, unknown>
30 for (const key of ['error', 'message']) {
31 const candidate = body[key]
32 if (typeof candidate === 'string' && candidate.trim()) return candidate
33 }
34 }
35 return ''
36}
37
38function envelope(response: RawResponse): ApiEnvelopeShape {
39 const body = response.data
40 if (body && typeof body === 'object') {
41 const record = body as Record<string, unknown>
42 if (typeof record.code === 'number') return record as unknown as ApiEnvelopeShape
43 }
44 const status = response.status
45 return {
46 code: status,
47 message: text(body) || (status >= 500 ? '服务未响应(网关错误)' : status >= 400 ? '服务返回了非接口数据' : ''),
48 data: null,
49 error: null,
50 error_code: null,
51 }
52}
53
54function fail(error: unknown): never {
55 if (error instanceof DOMException && error.name === 'AbortError') throw error
56 const wrapped = new Error(error instanceof Error ? error.message : 'network request failed')
57 wrapped.name = 'NetworkError'
58 throw wrapped
59}
60
61function query(params: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
62 if (!params) return undefined
63 const out: Record<string, unknown> = {}
64 for (const [key, value] of Object.entries(params)) {
65 if (value !== undefined && value !== null && value !== '') out[key] = value
66 }
67 return Object.keys(out).length ? out : undefined
68}
69
70function normalize(key: string): string {
71 return key.toLowerCase().replace(/[-_]/g, '')
72}
73
74const ALIASES: Record<string, string> = {
75 'space.listspaces': 'space.space_list',
76 'space.createspace': 'space.space_create',
77 'space.getspace': 'space.space_get',
78 'space.updatespace': 'space.space_update',
79 'space.deletespace': 'space.space_delete',
80 'space.restorespace': 'space.space_restore',
81 'space.listspacemembers': 'space.space_member_list',
82 'space.addmember': 'space.space_member_add',
83 'space.getmember': 'space.space_member_get',
84 'space.updatemember': 'space.space_member_update',
85 'space.updateselfmember': 'space.space_member_update_self',
86 'space.removemember': 'space.space_member_remove',
87 'space.listbans': 'space.space_ban_list',
88 'space.getban': 'space.space_ban_get',
89 'space.updateban': 'space.space_ban_update',
90 'space.unbanuser': 'space.space_ban_delete',
91 'space.listinvites': 'space.space_invite_list',
92 'space.createinvite': 'space.space_invite_create',
93 'space.revokeinvite': 'space.space_invite_revoke',
94 'remotes.fetch': 'remotes.fetch_remote',
95 'tag_protection.listpolicies': 'tag_protection.list_tag_policies',
96 'tag_protection.createpolicy': 'tag_protection.create_tag_policy',
97 'tag_protection.getpolicy': 'tag_protection.get_tag_policy',
98 'tag_protection.updatepolicy': 'tag_protection.update_tag_policy',
99 'tag_protection.deletepolicy': 'tag_protection.delete_tag_policy',
100}
101
102const RESOLVED = new Map<string, { key: string; entry: (typeof OPS)[string] }>()
103for (const [key, entry] of Object.entries(OPS)) {
104 const table = RESOLVED as Map<string, { key: string; entry: (typeof OPS)[string] }>
105 table.set(`${normalize(key)}`, { key, entry })
106}
107for (const [from, to] of Object.entries(ALIASES)) {
108 const hit = RESOLVED.get(normalize(to))
109 if (hit) RESOLVED.set(normalize(from), hit)
110}
111
112export const MISSING_OPS = new Set<string>()
113
114async function invoke(tag: string, op: string, config: ApiCallConfig = {}): Promise<ApiEnvelopeShape> {
115 const lookup = RESOLVED.get(`${normalize(tag)}.${normalize(op)}`)
116 if (!lookup) {
117 MISSING_OPS.add(`${tag}.${op}`)
118 return {
119 code: 501,
120 message: `未提供接口:${tag}.${op}`,
121 data: null,
122 error: null,
123 error_code: 'not_implemented',
124 }
125 }
126 const { entry } = lookup
127 const names = Object.keys(config.pathParams ?? {}).slice(0, entry.path)
128 const args: unknown[] = []
129 if (entry.path > 0 && names.length !== entry.path) {
130 return {
131 code: 400,
132 message: `缺少路径参数:${tag}.${op}`,
133 data: null,
134 error: null,
135 error_code: 'bad_request',
136 }
137 }
138 for (const name of names) args.push(config.pathParams?.[name])
139 if (entry.query) args.push(query(config.params))
140 if (entry.body) args.push(config.data)
141 const options: RequestInit = { credentials: 'same-origin' }
142 if (config.headers) options.headers = config.headers
143 if (config.signal) options.signal = config.signal
144 args.push(options)
145
146 try {
147 const response = (await entry.fn(...args)) as RawResponse
148 const body = envelope(response)
149 if (response.status >= 400 && !body.error_code && typeof body.error === 'string') {
150 body.error_code = null
151 }
152 return { ...body, code: response.status >= 400 ? response.status : body.code }
153 } catch (error) {
154 fail(error)
155 }
156}
157
158type ProxyFn = (config?: ApiCallConfig) => Promise<ApiEnvelopeShape>
159
160export const Apis: Record<string, Record<string, ProxyFn>> = new Proxy(
161 {},
162 {
163 get: (_target, tag: string) =>
164 new Proxy(
165 {},
166 {
167 get: (_scope, op: string) => (config?: ApiCallConfig) => invoke(tag, op, config),
168 },
169 ),
170 },
171) as Record<string, Record<string, ProxyFn>>
172
173export function trackFetch(input: string, init?: RequestInit): Promise<Response> {
174 return fetch(input, { credentials: 'same-origin', ...init })
175}
176
177export function newTraceparent(): string {
178 const hex = (n: number) => Array.from({ length: n }, () => Math.floor(Math.random() * 16).toString(16)).join('')
179 return `00-${hex(32)}-${hex(16)}-01`
180}
181
182export type * from '@kenapi/apiclient/index.schemas'
183export type {
184 AddMemberBody,
185 BanMemberBody,
186 CreateInviteBody,
187 CreateOrganizationBody,
188 EnvelopeCreateInviteResponse,
189 EnvelopePermissionCheck,
190 InviteSummary,
191 MemberSummary,
192 OrganizationSummary,
193 RedeemInviteBody,
194 SpaceJoinRequestState,
195 SpaceMemberRole,
196 UpdateBanBody,
197 UpdateMemberBody,
198 UpdateOrganizationBody,
199} from '@kenapi/apiclient/index.schemas'
200export * from './ops'
201
202export type EnvelopeInviteSummary = import('@kenapi/apiclient/index.schemas').EnvelopeCreateInviteResponse
203export interface RoomMemberRow {
204 user: { user_id: number; username: string; display_name: string; avatar_url: string }
205 user_id?: number
206 username?: string
207 display_name?: string | null
208 avatar_url?: string | null
209 role?: string
210 nick?: string | null
211 role_keys?: string[]
212 joined_at?: string | null
213 muted?: boolean
214 permissions?: string[]
215}
216export type EnvelopeVecRoomMemberDto = { data?: RoomMemberRow[] | null }
217export type CreateInviteCmd = import('@kenapi/apiclient/index.schemas').CreateInviteBody
218export type UpdateMemberCmd = import('@kenapi/apiclient/index.schemas').UpdateMemberBody
219export type UpdateSpaceCmd = import('@kenapi/apiclient/index.schemas').UpdateOrganizationBody
220export type CreateSpaceCmd = import('@kenapi/apiclient/index.schemas').CreateOrganizationBody
221export type AcceptInviteCmd = import('@kenapi/apiclient/index.schemas').RedeemInviteBody
222export type AddMemberCmd = import('@kenapi/apiclient/index.schemas').AddMemberBody
223export type BanUserCmd = import('@kenapi/apiclient/index.schemas').BanMemberBody
224export type UpdateBanCmd = import('@kenapi/apiclient/index.schemas').UpdateBanBody
225export type SpaceJoinRequestStatus = import('@kenapi/apiclient/index.schemas').SpaceJoinRequestState
226
227export default Apis
Added view/app/src/services/api/ops.ts View view/app/src/services/api/ops.ts
@@ -0,0 +1,626 @@
1// generated from openapi.json + packages/apiclient signatures
2import * as api from '@kenapi/apiclient'
3
4export interface OpEntry {
5 fn: (...args: unknown[]) => Promise<unknown>
6 path: number
7 query: boolean
8 body: boolean
9}
10
11export const OPS: Record<string, OpEntry> = {
12 'account.accept_agreement': { fn: api.acceptAgreement as OpEntry['fn'], path: 1, query: false, body: true },
13 'account.account_deletion_status': {
14 fn: api.accountDeletionStatus as OpEntry['fn'],
15 path: 0,
16 query: false,
17 body: true,
18 },
19 'account.cancel_account_deletion': {
20 fn: api.cancelAccountDeletion as OpEntry['fn'],
21 path: 0,
22 query: false,
23 body: true,
24 },
25 'account.get_preferences': { fn: api.getPreferences as OpEntry['fn'], path: 0, query: false, body: true },
26 'account.list_agreements': { fn: api.listAgreements as OpEntry['fn'], path: 0, query: false, body: true },
27 'account.list_devices': { fn: api.listDevices as OpEntry['fn'], path: 0, query: false, body: true },
28 'account.mark_notifications_read': {
29 fn: api.markNotificationsRead as OpEntry['fn'],
30 path: 0,
31 query: false,
32 body: true,
33 },
34 'account.me': { fn: api.me as OpEntry['fn'], path: 0, query: false, body: true },
35 'account.request_account_deletion': {
36 fn: api.requestAccountDeletion as OpEntry['fn'],
37 path: 0,
38 query: false,
39 body: true,
40 },
41 'account.update_preferences': { fn: api.updatePreferences as OpEntry['fn'], path: 0, query: false, body: true },
42 'account.update_profile': { fn: api.updateProfile as OpEntry['fn'], path: 0, query: false, body: true },
43 'account.update_username': { fn: api.updateUsername as OpEntry['fn'], path: 0, query: false, body: true },
44 'analysis.code_activity': { fn: api.codeActivity as OpEntry['fn'], path: 2, query: false, body: true },
45 'analysis.contributors': { fn: api.contributors as OpEntry['fn'], path: 2, query: false, body: true },
46 'analysis.languages': { fn: api.languages as OpEntry['fn'], path: 2, query: false, body: true },
47 'analysis.languages_read': { fn: api.languagesRead as OpEntry['fn'], path: 2, query: false, body: true },
48 'archives.create_archive': { fn: api.createArchive as OpEntry['fn'], path: 2, query: false, body: true },
49 'archives.create_bundle': { fn: api.createBundle as OpEntry['fn'], path: 2, query: false, body: true },
50 'archives.get_archive': { fn: api.getArchive as OpEntry['fn'], path: 3, query: false, body: true },
51 'auth.auth_challenge': { fn: api.authChallenge as OpEntry['fn'], path: 0, query: false, body: true },
52 'auth.auth_login': { fn: api.authLogin as OpEntry['fn'], path: 0, query: false, body: true },
53 'auth.auth_login_code': { fn: api.authLoginCode as OpEntry['fn'], path: 0, query: false, body: true },
54 'auth.auth_login_code_verify': { fn: api.authLoginCodeVerify as OpEntry['fn'], path: 0, query: false, body: true },
55 'auth.auth_login_verify': { fn: api.authLoginVerify as OpEntry['fn'], path: 0, query: false, body: true },
56 'auth.auth_logout': { fn: api.authLogout as OpEntry['fn'], path: 0, query: false, body: true },
57 'auth.auth_me': { fn: api.authMe as OpEntry['fn'], path: 0, query: false, body: true },
58 'auth.auth_password_reset': { fn: api.authPasswordReset as OpEntry['fn'], path: 0, query: false, body: true },
59 'auth.auth_password_reset_request': {
60 fn: api.authPasswordResetRequest as OpEntry['fn'],
61 path: 0,
62 query: false,
63 body: true,
64 },
65 'auth.auth_register': { fn: api.authRegister as OpEntry['fn'], path: 0, query: false, body: true },
66 'auth.auth_register_code': { fn: api.authRegisterCode as OpEntry['fn'], path: 0, query: false, body: true },
67 'auth.auth_ssh_key_add': { fn: api.authSshKeyAdd as OpEntry['fn'], path: 0, query: false, body: true },
68 'auth.auth_ssh_key_delete': { fn: api.authSshKeyDelete as OpEntry['fn'], path: 1, query: false, body: true },
69 'auth.auth_ssh_key_list': { fn: api.authSshKeyList as OpEntry['fn'], path: 0, query: false, body: true },
70 'auth.change_password': { fn: api.changePassword as OpEntry['fn'], path: 0, query: false, body: true },
71 'auth.recovery_codes': { fn: api.recoveryCodes as OpEntry['fn'], path: 0, query: false, body: true },
72 'auth.regenerate_recovery_codes': {
73 fn: api.regenerateRecoveryCodes as OpEntry['fn'],
74 path: 0,
75 query: false,
76 body: true,
77 },
78 'auth.totp_disable': { fn: api.totpDisable as OpEntry['fn'], path: 0, query: false, body: true },
79 'auth.totp_enable': { fn: api.totpEnable as OpEntry['fn'], path: 0, query: false, body: true },
80 'auth.totp_setup': { fn: api.totpSetup as OpEntry['fn'], path: 0, query: false, body: true },
81 'auth.totp_status': { fn: api.totpStatus as OpEntry['fn'], path: 0, query: false, body: true },
82 'blame.blame': { fn: api.blame as OpEntry['fn'], path: 2, query: false, body: true },
83 'blobs.blob_content': { fn: api.blobContent as OpEntry['fn'], path: 3, query: false, body: true },
84 'blobs.blob_metadata': { fn: api.blobMetadata as OpEntry['fn'], path: 3, query: false, body: true },
85 'blobs.list_lfs_pointers': { fn: api.listLfsPointers as OpEntry['fn'], path: 2, query: false, body: true },
86 'branch_protection.add_branch_whitelist': {
87 fn: api.addBranchWhitelist as OpEntry['fn'],
88 path: 3,
89 query: false,
90 body: true,
91 },
92 'branch_protection.list_branch_whitelists': {
93 fn: api.listBranchWhitelists as OpEntry['fn'],
94 path: 3,
95 query: false,
96 body: true,
97 },
98 'branch_protection.remove_branch_whitelist': {
99 fn: api.removeBranchWhitelist as OpEntry['fn'],
100 path: 4,
101 query: false,
102 body: true,
103 },
104 'branches.create_branch': { fn: api.createBranch as OpEntry['fn'], path: 2, query: false, body: true },
105 'branches.delete_branch': { fn: api.deleteBranch as OpEntry['fn'], path: 3, query: false, body: true },
106 'branches.get_branch': { fn: api.getBranch as OpEntry['fn'], path: 3, query: false, body: true },
107 'branches.list_branches': { fn: api.listBranches as OpEntry['fn'], path: 2, query: false, body: true },
108 'branches.rename_branch': { fn: api.renameBranch as OpEntry['fn'], path: 3, query: false, body: true },
109 'branches.set_default_branch': { fn: api.setDefaultBranch as OpEntry['fn'], path: 3, query: false, body: true },
110 'chat.add_reaction': { fn: api.addReaction as OpEntry['fn'], path: 2, query: false, body: true },
111 'chat.approve_hand': { fn: api.approveHand as OpEntry['fn'], path: 2, query: false, body: true },
112 'chat.bulk_delete_messages': { fn: api.bulkDeleteMessages as OpEntry['fn'], path: 1, query: false, body: true },
113 'chat.cancel_hand': { fn: api.cancelHand as OpEntry['fn'], path: 1, query: false, body: true },
114 'chat.cancel_reminder': { fn: api.cancelReminder as OpEntry['fn'], path: 1, query: false, body: true },
115 'chat.close_dm': { fn: api.closeDm as OpEntry['fn'], path: 1, query: false, body: true },
116 'chat.create_attachment': { fn: api.createAttachment as OpEntry['fn'], path: 0, query: false, body: true },
117 'chat.create_bookmark': { fn: api.createBookmark as OpEntry['fn'], path: 1, query: false, body: true },
118 'chat.create_outbound_webhook': { fn: api.createOutboundWebhook as OpEntry['fn'], path: 1, query: false, body: true },
119 'chat.create_reminder': { fn: api.createReminder as OpEntry['fn'], path: 2, query: false, body: true },
120 'chat.create_room': { fn: api.createRoom as OpEntry['fn'], path: 1, query: false, body: true },
121 'chat.create_thread': { fn: api.createThread as OpEntry['fn'], path: 2, query: false, body: true },
122 'chat.create_webhook': { fn: api.createWebhook as OpEntry['fn'], path: 1, query: false, body: true },
123 'chat.delete_bookmark': { fn: api.deleteBookmark as OpEntry['fn'], path: 2, query: false, body: true },
124 'chat.delete_draft': { fn: api.deleteDraft as OpEntry['fn'], path: 1, query: false, body: true },
125 'chat.delete_emoji': { fn: api.deleteEmoji as OpEntry['fn'], path: 2, query: false, body: true },
126 'chat.delete_message': { fn: api.deleteMessage as OpEntry['fn'], path: 2, query: false, body: true },
127 'chat.delete_outbound_webhook': { fn: api.deleteOutboundWebhook as OpEntry['fn'], path: 2, query: false, body: true },
128 'chat.delete_room': { fn: api.deleteRoom as OpEntry['fn'], path: 1, query: false, body: true },
129 'chat.delete_webhook': { fn: api.deleteWebhook as OpEntry['fn'], path: 2, query: false, body: true },
130 'chat.deny_hand': { fn: api.denyHand as OpEntry['fn'], path: 2, query: false, body: true },
131 'chat.download_attachment': { fn: api.downloadAttachment as OpEntry['fn'], path: 1, query: false, body: true },
132 'chat.emoji_image': { fn: api.emojiImage as OpEntry['fn'], path: 2, query: false, body: true },
133 'chat.finalize_attachment': { fn: api.finalizeAttachment as OpEntry['fn'], path: 1, query: false, body: true },
134 'chat.follow_thread': { fn: api.followThread as OpEntry['fn'], path: 1, query: false, body: true },
135 'chat.forward_message': { fn: api.forwardMessage as OpEntry['fn'], path: 2, query: false, body: true },
136 'chat.get_attachment': { fn: api.getAttachment as OpEntry['fn'], path: 1, query: false, body: true },
137 'chat.get_draft': { fn: api.getDraft as OpEntry['fn'], path: 1, query: false, body: true },
138 'chat.get_emoji': { fn: api.getEmoji as OpEntry['fn'], path: 2, query: false, body: true },
139 'chat.get_message': { fn: api.getMessage as OpEntry['fn'], path: 2, query: false, body: true },
140 'chat.get_presence': { fn: api.getPresence as OpEntry['fn'], path: 1, query: false, body: true },
141 'chat.get_read_state': { fn: api.getReadState as OpEntry['fn'], path: 1, query: false, body: true },
142 'chat.get_room': { fn: api.getRoom as OpEntry['fn'], path: 1, query: false, body: true },
143 'chat.get_room_setting': { fn: api.getRoomSetting as OpEntry['fn'], path: 1, query: false, body: true },
144 'chat.get_thread': { fn: api.getThread as OpEntry['fn'], path: 1, query: false, body: true },
145 'chat.get_voice_layout': { fn: api.getVoiceLayout as OpEntry['fn'], path: 1, query: false, body: true },
146 'chat.kick_participant': { fn: api.kickParticipant as OpEntry['fn'], path: 2, query: false, body: true },
147 'chat.list_bookmarks': { fn: api.listBookmarks as OpEntry['fn'], path: 1, query: false, body: true },
148 'chat.list_dms': { fn: api.listDms as OpEntry['fn'], path: 0, query: false, body: true },
149 'chat.list_emojis': { fn: api.listEmojis as OpEntry['fn'], path: 1, query: false, body: true },
150 'chat.list_hands': { fn: api.listHands as OpEntry['fn'], path: 1, query: false, body: true },
151 'chat.list_message_history': { fn: api.listMessageHistory as OpEntry['fn'], path: 2, query: false, body: true },
152 'chat.list_messages': { fn: api.listMessages as OpEntry['fn'], path: 1, query: false, body: true },
153 'chat.list_my_drafts': { fn: api.listMyDrafts as OpEntry['fn'], path: 0, query: false, body: true },
154 'chat.list_my_threads': { fn: api.listMyThreads as OpEntry['fn'], path: 0, query: false, body: true },
155 'chat.list_outbound_webhooks': { fn: api.listOutboundWebhooks as OpEntry['fn'], path: 1, query: false, body: true },
156 'chat.list_pins': { fn: api.listPins as OpEntry['fn'], path: 1, query: false, body: true },
157 'chat.list_reactions': { fn: api.listReactions as OpEntry['fn'], path: 2, query: false, body: true },
158 'chat.list_reminders': { fn: api.listReminders as OpEntry['fn'], path: 0, query: false, body: true },
159 'chat.list_space_presences': { fn: api.listSpacePresences as OpEntry['fn'], path: 1, query: false, body: true },
160 'chat.list_space_read_states': { fn: api.listSpaceReadStates as OpEntry['fn'], path: 1, query: false, body: true },
161 'chat.list_space_rooms': { fn: api.listSpaceRooms as OpEntry['fn'], path: 1, query: false, body: true },
162 'chat.list_threads': { fn: api.listThreads as OpEntry['fn'], path: 1, query: false, body: true },
163 'chat.list_webhooks': { fn: api.listWebhooks as OpEntry['fn'], path: 1, query: false, body: true },
164 'chat.livekit_webhook': { fn: api.livekitWebhook as OpEntry['fn'], path: 0, query: false, body: true },
165 'chat.mark_read': { fn: api.markRead as OpEntry['fn'], path: 1, query: false, body: true },
166 'chat.mark_thread_read': { fn: api.markThreadRead as OpEntry['fn'], path: 1, query: false, body: true },
167 'chat.moderate_mute': { fn: api.moderateMute as OpEntry['fn'], path: 2, query: false, body: true },
168 'chat.open_dm': { fn: api.openDm as OpEntry['fn'], path: 0, query: false, body: true },
169 'chat.post_via_webhook': { fn: api.postViaWebhook as OpEntry['fn'], path: 1, query: false, body: true },
170 'chat.raise_hand': { fn: api.raiseHand as OpEntry['fn'], path: 1, query: false, body: true },
171 'chat.remove_reaction': { fn: api.removeReaction as OpEntry['fn'], path: 2, query: false, body: true },
172 'chat.revoke_hand': { fn: api.revokeHand as OpEntry['fn'], path: 2, query: false, body: true },
173 'chat.save_voice_layout': { fn: api.saveVoiceLayout as OpEntry['fn'], path: 1, query: false, body: true },
174 'chat.send_message': { fn: api.sendMessage as OpEntry['fn'], path: 1, query: false, body: true },
175 'chat.set_message_pin': { fn: api.setMessagePin as OpEntry['fn'], path: 2, query: false, body: true },
176 'chat.set_presence': { fn: api.setPresence as OpEntry['fn'], path: 0, query: false, body: true },
177 'chat.start_recording': { fn: api.startRecording as OpEntry['fn'], path: 1, query: false, body: true },
178 'chat.stop_recording': { fn: api.stopRecording as OpEntry['fn'], path: 1, query: false, body: true },
179 'chat.unfollow_thread': { fn: api.unfollowThread as OpEntry['fn'], path: 1, query: false, body: true },
180 'chat.update_bookmark': { fn: api.updateBookmark as OpEntry['fn'], path: 2, query: false, body: true },
181 'chat.update_message': { fn: api.updateMessage as OpEntry['fn'], path: 2, query: false, body: true },
182 'chat.update_outbound_webhook': { fn: api.updateOutboundWebhook as OpEntry['fn'], path: 2, query: false, body: true },
183 'chat.update_reminder': { fn: api.updateReminder as OpEntry['fn'], path: 1, query: false, body: true },
184 'chat.update_room': { fn: api.updateRoom as OpEntry['fn'], path: 1, query: false, body: true },
185 'chat.update_room_setting': { fn: api.updateRoomSetting as OpEntry['fn'], path: 1, query: false, body: true },
186 'chat.update_thread': { fn: api.updateThread as OpEntry['fn'], path: 1, query: false, body: true },
187 'chat.update_webhook': { fn: api.updateWebhook as OpEntry['fn'], path: 2, query: false, body: true },
188 'chat.upload_emoji': { fn: api.uploadEmoji as OpEntry['fn'], path: 1, query: false, body: true },
189 'chat.upsert_draft': { fn: api.upsertDraft as OpEntry['fn'], path: 1, query: false, body: true },
190 'chat.voice_config': { fn: api.voiceConfig as OpEntry['fn'], path: 0, query: false, body: true },
191 'chat.voice_participants': { fn: api.voiceParticipants as OpEntry['fn'], path: 1, query: false, body: true },
192 'chat.voice_space_states': { fn: api.voiceSpaceStates as OpEntry['fn'], path: 1, query: false, body: true },
193 'chat.voice_token': { fn: api.voiceToken as OpEntry['fn'], path: 1, query: false, body: true },
194 'cleanup.clear_cleanup_overwrites': {
195 fn: api.clearCleanupOverwrites as OpEntry['fn'],
196 path: 3,
197 query: false,
198 body: true,
199 },
200 'cleanup.delete_cleanup_object': { fn: api.deleteCleanupObject as OpEntry['fn'], path: 3, query: false, body: true },
201 'cleanup.list_cleanup_objects': { fn: api.listCleanupObjects as OpEntry['fn'], path: 1, query: false, body: true },
202 'cleanup.set_cleanup_archive': { fn: api.setCleanupArchive as OpEntry['fn'], path: 3, query: false, body: true },
203 'cleanup.set_cleanup_visibility': {
204 fn: api.setCleanupVisibility as OpEntry['fn'],
205 path: 3,
206 query: false,
207 body: true,
208 },
209 'code-comments.create_code_comment': {
210 fn: api.createCodeComment as OpEntry['fn'],
211 path: 2,
212 query: false,
213 body: true,
214 },
215 'code-comments.delete_code_comment': {
216 fn: api.deleteCodeComment as OpEntry['fn'],
217 path: 3,
218 query: false,
219 body: true,
220 },
221 'code-comments.list_code_comments': { fn: api.listCodeComments as OpEntry['fn'], path: 2, query: false, body: true },
222 'code-comments.resolve_code_comment': {
223 fn: api.resolveCodeComment as OpEntry['fn'],
224 path: 3,
225 query: false,
226 body: true,
227 },
228 'code-comments.unresolve_code_comment': {
229 fn: api.unresolveCodeComment as OpEntry['fn'],
230 path: 3,
231 query: false,
232 body: true,
233 },
234 'code-comments.update_code_comment': {
235 fn: api.updateCodeComment as OpEntry['fn'],
236 path: 3,
237 query: false,
238 body: true,
239 },
240 'commits.commit_stats': { fn: api.commitStats as OpEntry['fn'], path: 2, query: false, body: true },
241 'commits.get_commit': { fn: api.getCommit as OpEntry['fn'], path: 3, query: false, body: true },
242 'commits.is_ancestor': { fn: api.isAncestor as OpEntry['fn'], path: 2, query: false, body: true },
243 'commits.list_commit_statuses': { fn: api.listCommitStatuses as OpEntry['fn'], path: 3, query: false, body: true },
244 'commits.list_commits': { fn: api.listCommits as OpEntry['fn'], path: 2, query: false, body: true },
245 'commits.rev_parse': { fn: api.revParse as OpEntry['fn'], path: 3, query: false, body: true },
246 'commits.set_commit_status': { fn: api.setCommitStatus as OpEntry['fn'], path: 2, query: false, body: true },
247 'conflicts.conflict_detail': { fn: api.conflictDetail as OpEntry['fn'], path: 2, query: false, body: true },
248 'conflicts.get_conflicts': { fn: api.getConflicts as OpEntry['fn'], path: 2, query: false, body: true },
249 'content.caller_signature': { fn: api.callerSignature as OpEntry['fn'], path: 0, query: false, body: true },
250 'diffs.diff_for_commit': { fn: api.diffForCommit as OpEntry['fn'], path: 3, query: false, body: true },
251 'diffs.diff_raw': { fn: api.diffRaw as OpEntry['fn'], path: 2, query: false, body: true },
252 'diffs.get_diff': { fn: api.getDiff as OpEntry['fn'], path: 2, query: false, body: true },
253 'diffs.range_diff': { fn: api.rangeDiff as OpEntry['fn'], path: 2, query: false, body: true },
254 'hooks.execute_script': { fn: api.executeScript as OpEntry['fn'], path: 3, query: false, body: true },
255 'issues.add_comment_reaction': { fn: api.addCommentReaction as OpEntry['fn'], path: 4, query: false, body: true },
256 'issues.add_issue_reaction': { fn: api.addIssueReaction as OpEntry['fn'], path: 3, query: false, body: true },
257 'issues.bind_label': { fn: api.bindLabel as OpEntry['fn'], path: 4, query: false, body: true },
258 'issues.bulk_issues': { fn: api.bulkIssues as OpEntry['fn'], path: 2, query: false, body: true },
259 'issues.close_issue': { fn: api.closeIssue as OpEntry['fn'], path: 3, query: false, body: true },
260 'issues.create_board': { fn: api.createBoard as OpEntry['fn'], path: 1, query: false, body: true },
261 'issues.create_comment': { fn: api.createComment as OpEntry['fn'], path: 3, query: false, body: true },
262 'issues.create_dependency': { fn: api.createDependency as OpEntry['fn'], path: 3, query: false, body: true },
263 'issues.create_issue': { fn: api.createIssue as OpEntry['fn'], path: 2, query: false, body: true },
264 'issues.create_issue_attachment': {
265 fn: api.createIssueAttachment as OpEntry['fn'],
266 path: 0,
267 query: false,
268 body: true,
269 },
270 'issues.create_label': { fn: api.createLabel as OpEntry['fn'], path: 2, query: false, body: true },
271 'issues.create_member': { fn: api.createMember as OpEntry['fn'], path: 3, query: false, body: true },
272 'issues.create_milestone': { fn: api.createMilestone as OpEntry['fn'], path: 2, query: false, body: true },
273 'issues.create_pin': { fn: api.createPin as OpEntry['fn'], path: 2, query: false, body: true },
274 'issues.create_repo_relation': { fn: api.createRepoRelation as OpEntry['fn'], path: 3, query: false, body: true },
275 'issues.create_tracked_time': { fn: api.createTrackedTime as OpEntry['fn'], path: 3, query: false, body: true },
276 'issues.delete_board': { fn: api.deleteBoard as OpEntry['fn'], path: 2, query: false, body: true },
277 'issues.delete_comment': { fn: api.deleteComment as OpEntry['fn'], path: 4, query: false, body: true },
278 'issues.delete_dependency': { fn: api.deleteDependency as OpEntry['fn'], path: 4, query: false, body: true },
279 'issues.delete_issue': { fn: api.deleteIssue as OpEntry['fn'], path: 3, query: false, body: true },
280 'issues.delete_issue_attachment': {
281 fn: api.deleteIssueAttachment as OpEntry['fn'],
282 path: 1,
283 query: false,
284 body: true,
285 },
286 'issues.delete_label': { fn: api.deleteLabel as OpEntry['fn'], path: 3, query: false, body: true },
287 'issues.delete_member': { fn: api.deleteMember as OpEntry['fn'], path: 4, query: false, body: true },
288 'issues.delete_milestone': { fn: api.deleteMilestone as OpEntry['fn'], path: 3, query: false, body: true },
289 'issues.delete_pin': { fn: api.deletePin as OpEntry['fn'], path: 3, query: false, body: true },
290 'issues.delete_repo_relation': { fn: api.deleteRepoRelation as OpEntry['fn'], path: 4, query: false, body: true },
291 'issues.delete_tracked_time': { fn: api.deleteTrackedTime as OpEntry['fn'], path: 4, query: false, body: true },
292 'issues.download_issue_attachment': {
293 fn: api.downloadIssueAttachment as OpEntry['fn'],
294 path: 1,
295 query: false,
296 body: true,
297 },
298 'issues.finalize_issue_attachment': {
299 fn: api.finalizeIssueAttachment as OpEntry['fn'],
300 path: 1,
301 query: false,
302 body: true,
303 },
304 'issues.get_board': { fn: api.getBoard as OpEntry['fn'], path: 2, query: false, body: true },
305 'issues.get_comment': { fn: api.getComment as OpEntry['fn'], path: 4, query: false, body: true },
306 'issues.get_issue': { fn: api.getIssue as OpEntry['fn'], path: 3, query: false, body: true },
307 'issues.get_label': { fn: api.getLabel as OpEntry['fn'], path: 3, query: false, body: true },
308 'issues.get_milestone': { fn: api.getMilestone as OpEntry['fn'], path: 3, query: false, body: true },
309 'issues.get_stopwatch': { fn: api.getStopwatch as OpEntry['fn'], path: 3, query: false, body: true },
310 'issues.list_board_pins': { fn: api.listBoardPins as OpEntry['fn'], path: 2, query: false, body: true },
311 'issues.list_boards': { fn: api.listBoards as OpEntry['fn'], path: 1, query: false, body: true },
312 'issues.list_comment_reactions': { fn: api.listCommentReactions as OpEntry['fn'], path: 4, query: false, body: true },
313 'issues.list_comments': { fn: api.listComments as OpEntry['fn'], path: 3, query: false, body: true },
314 'issues.list_comments_tree': { fn: api.listCommentsTree as OpEntry['fn'], path: 3, query: false, body: true },
315 'issues.list_content_history': { fn: api.listContentHistory as OpEntry['fn'], path: 3, query: false, body: true },
316 'issues.list_dependencies': { fn: api.listDependencies as OpEntry['fn'], path: 3, query: false, body: true },
317 'issues.list_dependents': { fn: api.listDependents as OpEntry['fn'], path: 3, query: false, body: true },
318 'issues.list_events': { fn: api.listEvents as OpEntry['fn'], path: 3, query: false, body: true },
319 'issues.list_issue_attachments': { fn: api.listIssueAttachments as OpEntry['fn'], path: 3, query: false, body: true },
320 'issues.list_issue_members': { fn: api.listIssueMembers as OpEntry['fn'], path: 3, query: false, body: true },
321 'issues.list_issue_reactions': { fn: api.listIssueReactions as OpEntry['fn'], path: 3, query: false, body: true },
322 'issues.list_issue_users': { fn: api.listIssueUsers as OpEntry['fn'], path: 3, query: false, body: true },
323 'issues.list_issues': { fn: api.listIssues as OpEntry['fn'], path: 2, query: false, body: true },
324 'issues.list_labels': { fn: api.listLabels as OpEntry['fn'], path: 2, query: false, body: true },
325 'issues.list_labels_for_issue': { fn: api.listLabelsForIssue as OpEntry['fn'], path: 3, query: false, body: true },
326 'issues.list_milestones': { fn: api.listMilestones as OpEntry['fn'], path: 2, query: false, body: true },
327 'issues.list_repo_relations': { fn: api.listRepoRelations as OpEntry['fn'], path: 3, query: false, body: true },
328 'issues.list_tracked_time': { fn: api.listTrackedTime as OpEntry['fn'], path: 3, query: false, body: true },
329 'issues.mark_issue_read': { fn: api.markIssueRead as OpEntry['fn'], path: 3, query: false, body: true },
330 'issues.remove_comment_reaction': {
331 fn: api.removeCommentReaction as OpEntry['fn'],
332 path: 4,
333 query: false,
334 body: true,
335 },
336 'issues.remove_issue_reaction': { fn: api.removeIssueReaction as OpEntry['fn'], path: 3, query: false, body: true },
337 'issues.reopen_issue': { fn: api.reopenIssue as OpEntry['fn'], path: 3, query: false, body: true },
338 'issues.start_stopwatch': { fn: api.startStopwatch as OpEntry['fn'], path: 3, query: false, body: true },
339 'issues.stop_stopwatch': { fn: api.stopStopwatch as OpEntry['fn'], path: 3, query: false, body: true },
340 'issues.unbind_label': { fn: api.unbindLabel as OpEntry['fn'], path: 4, query: false, body: true },
341 'issues.update_board': { fn: api.updateBoard as OpEntry['fn'], path: 2, query: false, body: true },
342 'issues.update_comment': { fn: api.updateComment as OpEntry['fn'], path: 4, query: false, body: true },
343 'issues.update_issue': { fn: api.updateIssue as OpEntry['fn'], path: 3, query: false, body: true },
344 'issues.update_label': { fn: api.updateLabel as OpEntry['fn'], path: 3, query: false, body: true },
345 'issues.update_milestone': { fn: api.updateMilestone as OpEntry['fn'], path: 3, query: false, body: true },
346 'issues.update_pin': { fn: api.updatePin as OpEntry['fn'], path: 3, query: false, body: true },
347 'issues.update_tracked_time': { fn: api.updateTrackedTime as OpEntry['fn'], path: 4, query: false, body: true },
348 'issues.upsert_issue_watch': { fn: api.upsertIssueWatch as OpEntry['fn'], path: 3, query: false, body: true },
349 'lfs.list_locks': { fn: api.listLocks as OpEntry['fn'], path: 2, query: false, body: true },
350 'lfs.lock': { fn: api.lock as OpEntry['fn'], path: 2, query: false, body: true },
351 'lfs.unlock': { fn: api.unlock as OpEntry['fn'], path: 2, query: false, body: true },
352 'lfs.unlock_by_id': { fn: api.unlockById as OpEntry['fn'], path: 3, query: false, body: true },
353 'lfs.verify_locks': { fn: api.verifyLocks as OpEntry['fn'], path: 2, query: false, body: true },
354 'licenses.get_license': { fn: api.getLicense as OpEntry['fn'], path: 1, query: false, body: true },
355 'licenses.list_licenses': { fn: api.listLicenses as OpEntry['fn'], path: 0, query: false, body: true },
356 'logs.path_log': { fn: api.pathLog as OpEntry['fn'], path: 2, query: false, body: true },
357 'notifications.delete_notification': {
358 fn: api.deleteNotification as OpEntry['fn'],
359 path: 1,
360 query: false,
361 body: true,
362 },
363 'notifications.list_notifications': { fn: api.listNotifications as OpEntry['fn'], path: 0, query: false, body: true },
364 'notifications.unread_notifications_count': {
365 fn: api.unreadNotificationsCount as OpEntry['fn'],
366 path: 0,
367 query: false,
368 body: true,
369 },
370 'patches.patch_generate': { fn: api.patchGenerate as OpEntry['fn'], path: 2, query: false, body: true },
371 'patches.patch_parse': { fn: api.patchParse as OpEntry['fn'], path: 0, query: false, body: true },
372 'protection.create_policy': { fn: api.createPolicy as OpEntry['fn'], path: 2, query: false, body: true },
373 'protection.delete_policy': { fn: api.deletePolicy as OpEntry['fn'], path: 3, query: false, body: true },
374 'protection.get_policy': { fn: api.getPolicy as OpEntry['fn'], path: 3, query: false, body: true },
375 'protection.list_policies': { fn: api.listPolicies as OpEntry['fn'], path: 2, query: false, body: true },
376 'protection.update_policy': { fn: api.updatePolicy as OpEntry['fn'], path: 3, query: false, body: true },
377 'pulls.add_pull_assignee': { fn: api.addPullAssignee as OpEntry['fn'], path: 4, query: false, body: true },
378 'pulls.attach_pull_label': { fn: api.attachPullLabel as OpEntry['fn'], path: 3, query: false, body: true },
379 'pulls.close_pull': { fn: api.closePull as OpEntry['fn'], path: 3, query: false, body: true },
380 'pulls.create_pull': { fn: api.createPull as OpEntry['fn'], path: 2, query: false, body: true },
381 'pulls.create_pull_comment': { fn: api.createPullComment as OpEntry['fn'], path: 3, query: false, body: true },
382 'pulls.delete_pull': { fn: api.deletePull as OpEntry['fn'], path: 3, query: false, body: true },
383 'pulls.delete_pull_comment': { fn: api.deletePullComment as OpEntry['fn'], path: 4, query: false, body: true },
384 'pulls.detach_pull_label': { fn: api.detachPullLabel as OpEntry['fn'], path: 4, query: false, body: true },
385 'pulls.get_pull': { fn: api.getPull as OpEntry['fn'], path: 3, query: false, body: true },
386 'pulls.get_pull_checks': { fn: api.getPullChecks as OpEntry['fn'], path: 3, query: false, body: true },
387 'pulls.get_pull_files': { fn: api.getPullFiles as OpEntry['fn'], path: 3, query: false, body: true },
388 'pulls.get_pull_gate': { fn: api.getPullGate as OpEntry['fn'], path: 3, query: false, body: true },
389 'pulls.get_pull_patch': { fn: api.getPullPatch as OpEntry['fn'], path: 3, query: false, body: true },
390 'pulls.link_issue_pull': { fn: api.linkIssuePull as OpEntry['fn'], path: 3, query: false, body: true },
391 'pulls.list_issue_pulls': { fn: api.listIssuePulls as OpEntry['fn'], path: 3, query: false, body: true },
392 'pulls.list_label_options': { fn: api.listLabelOptions as OpEntry['fn'], path: 2, query: false, body: true },
393 'pulls.list_my_pulls': { fn: api.listMyPulls as OpEntry['fn'], path: 1, query: false, body: true },
394 'pulls.list_pull_assignees': { fn: api.listPullAssignees as OpEntry['fn'], path: 3, query: false, body: true },
395 'pulls.list_pull_comment_tree': { fn: api.listPullCommentTree as OpEntry['fn'], path: 3, query: false, body: true },
396 'pulls.list_pull_comments': { fn: api.listPullComments as OpEntry['fn'], path: 3, query: false, body: true },
397 'pulls.list_pull_commits': { fn: api.listPullCommits as OpEntry['fn'], path: 3, query: false, body: true },
398 'pulls.list_pull_events': { fn: api.listPullEvents as OpEntry['fn'], path: 3, query: false, body: true },
399 'pulls.list_pull_labels': { fn: api.listPullLabels as OpEntry['fn'], path: 3, query: false, body: true },
400 'pulls.list_pulls': { fn: api.listPulls as OpEntry['fn'], path: 2, query: false, body: true },
401 'pulls.lock_pull': { fn: api.lockPull as OpEntry['fn'], path: 3, query: false, body: true },
402 'pulls.merge_pull': { fn: api.mergePull as OpEntry['fn'], path: 3, query: false, body: true },
403 'pulls.refresh_pull_summary': { fn: api.refreshPullSummary as OpEntry['fn'], path: 3, query: false, body: true },
404 'pulls.remove_pull_assignee': { fn: api.removePullAssignee as OpEntry['fn'], path: 4, query: false, body: true },
405 'pulls.reopen_pull': { fn: api.reopenPull as OpEntry['fn'], path: 3, query: false, body: true },
406 'pulls.resolve_pull_comment': { fn: api.resolvePullComment as OpEntry['fn'], path: 4, query: false, body: true },
407 'pulls.unlink_issue_pull': { fn: api.unlinkIssuePull as OpEntry['fn'], path: 4, query: false, body: true },
408 'pulls.unlock_pull': { fn: api.unlockPull as OpEntry['fn'], path: 3, query: false, body: true },
409 'pulls.unresolve_pull_comment': { fn: api.unresolvePullComment as OpEntry['fn'], path: 4, query: false, body: true },
410 'pulls.update_branch_pull': { fn: api.updateBranchPull as OpEntry['fn'], path: 3, query: false, body: true },
411 'pulls.update_pull': { fn: api.updatePull as OpEntry['fn'], path: 3, query: false, body: true },
412 'pulls.update_pull_comment': { fn: api.updatePullComment as OpEntry['fn'], path: 4, query: false, body: true },
413 'push.push_config': { fn: api.pushConfig as OpEntry['fn'], path: 0, query: false, body: true },
414 'releases.create_release': { fn: api.createRelease as OpEntry['fn'], path: 2, query: false, body: true },
415 'releases.delete_release': { fn: api.deleteRelease as OpEntry['fn'], path: 3, query: false, body: true },
416 'releases.get_release': { fn: api.getRelease as OpEntry['fn'], path: 3, query: false, body: true },
417 'releases.list_releases': { fn: api.listReleases as OpEntry['fn'], path: 2, query: false, body: true },
418 'releases.update_release': { fn: api.updateRelease as OpEntry['fn'], path: 3, query: false, body: true },
419 'remotes.clone_import': { fn: api.cloneImport as OpEntry['fn'], path: 2, query: false, body: true },
420 'remotes.fetch_remote': { fn: api.fetchRemote as OpEntry['fn'], path: 2, query: false, body: true },
421 'remotes.list_remotes': { fn: api.listRemotes as OpEntry['fn'], path: 2, query: false, body: true },
422 'remotes.push': { fn: api.push as OpEntry['fn'], path: 2, query: false, body: true },
423 'remotes.remove': { fn: api.remove as OpEntry['fn'], path: 3, query: false, body: true },
424 'remotes.upsert_remote': { fn: api.upsertRemote as OpEntry['fn'], path: 3, query: false, body: true },
425 'reports.create_report': { fn: api.createReport as OpEntry['fn'], path: 0, query: false, body: true },
426 'repos.add_topic_to_repo': { fn: api.addTopicToRepo as OpEntry['fn'], path: 3, query: false, body: true },
427 'repos.archive_repo': { fn: api.archiveRepo as OpEntry['fn'], path: 2, query: false, body: true },
428 'repos.create_repo': { fn: api.createRepo as OpEntry['fn'], path: 1, query: false, body: true },
429 'repos.get_repo': { fn: api.getRepo as OpEntry['fn'], path: 2, query: false, body: true },
430 'repos.list_repo_topics': { fn: api.listRepoTopics as OpEntry['fn'], path: 2, query: false, body: true },
431 'repos.list_repos': { fn: api.listRepos as OpEntry['fn'], path: 1, query: false, body: true },
432 'repos.list_stargazers': { fn: api.listStargazers as OpEntry['fn'], path: 2, query: false, body: true },
433 'repos.list_watchers': { fn: api.listWatchers as OpEntry['fn'], path: 2, query: false, body: true },
434 'repos.remove_topic_from_repo': { fn: api.removeTopicFromRepo as OpEntry['fn'], path: 3, query: false, body: true },
435 'repos.star_repo': { fn: api.starRepo as OpEntry['fn'], path: 2, query: false, body: true },
436 'repos.unarchive_repo': { fn: api.unarchiveRepo as OpEntry['fn'], path: 2, query: false, body: true },
437 'repos.unstar_repo': { fn: api.unstarRepo as OpEntry['fn'], path: 2, query: false, body: true },
438 'repos.update_repo': { fn: api.updateRepo as OpEntry['fn'], path: 2, query: false, body: true },
439 'repos.upsert_watch': { fn: api.upsertWatch as OpEntry['fn'], path: 2, query: false, body: true },
440 'reviews.delete_requested_reviewer': {
441 fn: api.deleteRequestedReviewer as OpEntry['fn'],
442 path: 4,
443 query: false,
444 body: true,
445 },
446 'reviews.dismiss_review': { fn: api.dismissReview as OpEntry['fn'], path: 4, query: false, body: true },
447 'reviews.get_review': { fn: api.getReview as OpEntry['fn'], path: 4, query: false, body: true },
448 'reviews.list_requested_reviewers': {
449 fn: api.listRequestedReviewers as OpEntry['fn'],
450 path: 3,
451 query: false,
452 body: true,
453 },
454 'reviews.list_reviews': { fn: api.listReviews as OpEntry['fn'], path: 3, query: false, body: true },
455 'reviews.publish_review': { fn: api.publishReview as OpEntry['fn'], path: 4, query: false, body: true },
456 'reviews.request_reviewers': { fn: api.requestReviewers as OpEntry['fn'], path: 3, query: false, body: true },
457 'reviews.submit_review': { fn: api.submitReview as OpEntry['fn'], path: 3, query: false, body: true },
458 'signatures.verify_commit': { fn: api.verifyCommit as OpEntry['fn'], path: 3, query: false, body: true },
459 'signatures.verify_tag': { fn: api.verifyTag as OpEntry['fn'], path: 3, query: false, body: true },
460 'space.approve_join_request': { fn: api.approveJoinRequest as OpEntry['fn'], path: 2, query: false, body: true },
461 'space.assign_group': { fn: api.assignGroup as OpEntry['fn'], path: 2, query: false, body: true },
462 'space.create_dir_node': { fn: api.createDirNode as OpEntry['fn'], path: 1, query: false, body: true },
463 'space.create_group': { fn: api.createGroup as OpEntry['fn'], path: 1, query: false, body: true },
464 'space.create_join_request': { fn: api.createJoinRequest as OpEntry['fn'], path: 1, query: false, body: true },
465 'space.create_join_rule': { fn: api.createJoinRule as OpEntry['fn'], path: 1, query: false, body: true },
466 'space.delete_group': { fn: api.deleteGroup as OpEntry['fn'], path: 2, query: false, body: true },
467 'space.delete_join_rule': { fn: api.deleteJoinRule as OpEntry['fn'], path: 2, query: false, body: true },
468 'space.delete_space_image': { fn: api.deleteSpaceImage as OpEntry['fn'], path: 2, query: false, body: true },
469 'space.explore_spaces': { fn: api.exploreSpaces as OpEntry['fn'], path: 0, query: false, body: true },
470 'space.get_group': { fn: api.getGroup as OpEntry['fn'], path: 2, query: false, body: true },
471 'space.get_space_dir_tree': { fn: api.getSpaceDirTree as OpEntry['fn'], path: 1, query: false, body: true },
472 'space.get_space_image': { fn: api.getSpaceImage as OpEntry['fn'], path: 2, query: false, body: true },
473 'space.get_space_image_file': { fn: api.getSpaceImageFile as OpEntry['fn'], path: 2, query: false, body: true },
474 'space.join_group_self': { fn: api.joinGroupSelf as OpEntry['fn'], path: 2, query: false, body: true },
475 'space.leave_group_self': { fn: api.leaveGroupSelf as OpEntry['fn'], path: 2, query: false, body: true },
476 'space.list_dir_group_overwrites': {
477 fn: api.listDirGroupOverwrites as OpEntry['fn'],
478 path: 2,
479 query: false,
480 body: true,
481 },
482 'space.list_dir_user_overwrites': {
483 fn: api.listDirUserOverwrites as OpEntry['fn'],
484 path: 2,
485 query: false,
486 body: true,
487 },
488 'space.list_groups': { fn: api.listGroups as OpEntry['fn'], path: 1, query: false, body: true },
489 'space.list_join_requests': { fn: api.listJoinRequests as OpEntry['fn'], path: 1, query: false, body: true },
490 'space.list_join_rules': { fn: api.listJoinRules as OpEntry['fn'], path: 1, query: false, body: true },
491 'space.move_dir_node': { fn: api.moveDirNode as OpEntry['fn'], path: 2, query: false, body: true },
492 'space.reject_join_request': { fn: api.rejectJoinRequest as OpEntry['fn'], path: 2, query: false, body: true },
493 'space.remove_dir_group_overwrite': {
494 fn: api.removeDirGroupOverwrite as OpEntry['fn'],
495 path: 3,
496 query: false,
497 body: true,
498 },
499 'space.remove_dir_user_overwrite': {
500 fn: api.removeDirUserOverwrite as OpEntry['fn'],
501 path: 3,
502 query: false,
503 body: true,
504 },
505 'space.repair_dir_parents': { fn: api.repairDirParents as OpEntry['fn'], path: 1, query: false, body: true },
506 'space.set_dir_group_overwrite': { fn: api.setDirGroupOverwrite as OpEntry['fn'], path: 3, query: false, body: true },
507 'space.set_dir_user_overwrite': { fn: api.setDirUserOverwrite as OpEntry['fn'], path: 3, query: false, body: true },
508 'space.space_archive': { fn: api.spaceArchive as OpEntry['fn'], path: 1, query: false, body: true },
509 'space.space_ban_delete': { fn: api.spaceBanDelete as OpEntry['fn'], path: 2, query: false, body: true },
510 'space.space_ban_get': { fn: api.spaceBanGet as OpEntry['fn'], path: 2, query: false, body: true },
511 'space.space_ban_list': { fn: api.spaceBanList as OpEntry['fn'], path: 1, query: false, body: true },
512 'space.space_ban_update': { fn: api.spaceBanUpdate as OpEntry['fn'], path: 2, query: false, body: true },
513 'space.space_create': { fn: api.spaceCreate as OpEntry['fn'], path: 0, query: false, body: true },
514 'space.space_delete': { fn: api.spaceDelete as OpEntry['fn'], path: 1, query: false, body: true },
515 'space.space_get': { fn: api.spaceGet as OpEntry['fn'], path: 1, query: false, body: true },
516 'space.space_invite_create': { fn: api.spaceInviteCreate as OpEntry['fn'], path: 1, query: false, body: true },
517 'space.space_invite_list': { fn: api.spaceInviteList as OpEntry['fn'], path: 1, query: false, body: true },
518 'space.space_invite_redeem': { fn: api.spaceInviteRedeem as OpEntry['fn'], path: 0, query: false, body: true },
519 'space.space_invite_revoke': { fn: api.spaceInviteRevoke as OpEntry['fn'], path: 2, query: false, body: true },
520 'space.space_leave': { fn: api.spaceLeave as OpEntry['fn'], path: 1, query: false, body: true },
521 'space.space_list': { fn: api.spaceList as OpEntry['fn'], path: 0, query: false, body: true },
522 'space.space_member_add': { fn: api.spaceMemberAdd as OpEntry['fn'], path: 1, query: false, body: true },
523 'space.space_member_ban': { fn: api.spaceMemberBan as OpEntry['fn'], path: 2, query: false, body: true },
524 'space.space_member_get': { fn: api.spaceMemberGet as OpEntry['fn'], path: 2, query: false, body: true },
525 'space.space_member_list': { fn: api.spaceMemberList as OpEntry['fn'], path: 1, query: false, body: true },
526 'space.space_member_permissions': {
527 fn: api.spaceMemberPermissions as OpEntry['fn'],
528 path: 2,
529 query: false,
530 body: true,
531 },
532 'space.space_member_remove': { fn: api.spaceMemberRemove as OpEntry['fn'], path: 2, query: false, body: true },
533 'space.space_member_role': { fn: api.spaceMemberRole as OpEntry['fn'], path: 2, query: false, body: true },
534 'space.space_member_unban': { fn: api.spaceMemberUnban as OpEntry['fn'], path: 2, query: false, body: true },
535 'space.space_member_update': { fn: api.spaceMemberUpdate as OpEntry['fn'], path: 2, query: false, body: true },
536 'space.space_member_update_self': {
537 fn: api.spaceMemberUpdateSelf as OpEntry['fn'],
538 path: 1,
539 query: false,
540 body: true,
541 },
542 'space.space_permission_check': { fn: api.spacePermissionCheck as OpEntry['fn'], path: 1, query: false, body: true },
543 'space.space_restore': { fn: api.spaceRestore as OpEntry['fn'], path: 1, query: false, body: true },
544 'space.space_role_assign': { fn: api.spaceRoleAssign as OpEntry['fn'], path: 1, query: false, body: true },
545 'space.space_role_create': { fn: api.spaceRoleCreate as OpEntry['fn'], path: 1, query: false, body: true },
546 'space.space_role_delete': { fn: api.spaceRoleDelete as OpEntry['fn'], path: 2, query: false, body: true },
547 'space.space_role_get': { fn: api.spaceRoleGet as OpEntry['fn'], path: 2, query: false, body: true },
548 'space.space_role_join': { fn: api.spaceRoleJoin as OpEntry['fn'], path: 2, query: false, body: true },
549 'space.space_role_leave': { fn: api.spaceRoleLeave as OpEntry['fn'], path: 2, query: false, body: true },
550 'space.space_role_list': { fn: api.spaceRoleList as OpEntry['fn'], path: 1, query: false, body: true },
551 'space.space_role_permissions': { fn: api.spaceRolePermissions as OpEntry['fn'], path: 2, query: false, body: true },
552 'space.space_role_unassign': { fn: api.spaceRoleUnassign as OpEntry['fn'], path: 1, query: false, body: true },
553 'space.space_role_update': { fn: api.spaceRoleUpdate as OpEntry['fn'], path: 2, query: false, body: true },
554 'space.space_transfer': { fn: api.spaceTransfer as OpEntry['fn'], path: 1, query: false, body: true },
555 'space.space_update': { fn: api.spaceUpdate as OpEntry['fn'], path: 1, query: false, body: true },
556 'space.unassign_group': { fn: api.unassignGroup as OpEntry['fn'], path: 3, query: false, body: true },
557 'space.update_dir_node': { fn: api.updateDirNode as OpEntry['fn'], path: 2, query: false, body: true },
558 'space.update_group': { fn: api.updateGroup as OpEntry['fn'], path: 2, query: false, body: true },
559 'space.update_join_rule': { fn: api.updateJoinRule as OpEntry['fn'], path: 2, query: false, body: true },
560 'space.upload_space_image': { fn: api.uploadSpaceImage as OpEntry['fn'], path: 2, query: false, body: true },
561 'space.withdraw_join_request': { fn: api.withdrawJoinRequest as OpEntry['fn'], path: 2, query: false, body: true },
562 'submodules.submodule_list': { fn: api.submoduleList as OpEntry['fn'], path: 2, query: false, body: true },
563 'submodules.submodule_resolve': { fn: api.submoduleResolve as OpEntry['fn'], path: 2, query: false, body: true },
564 'tag_protection.add_tag_whitelist': { fn: api.addTagWhitelist as OpEntry['fn'], path: 3, query: false, body: true },
565 'tag_protection.create_tag_policy': { fn: api.createTagPolicy as OpEntry['fn'], path: 2, query: false, body: true },
566 'tag_protection.delete_tag_policy': { fn: api.deleteTagPolicy as OpEntry['fn'], path: 3, query: false, body: true },
567 'tag_protection.get_tag_policy': { fn: api.getTagPolicy as OpEntry['fn'], path: 3, query: false, body: true },
568 'tag_protection.list_tag_policies': { fn: api.listTagPolicies as OpEntry['fn'], path: 2, query: false, body: true },
569 'tag_protection.list_tag_whitelists': {
570 fn: api.listTagWhitelists as OpEntry['fn'],
571 path: 3,
572 query: false,
573 body: true,
574 },
575 'tag_protection.remove_tag_whitelist': {
576 fn: api.removeTagWhitelist as OpEntry['fn'],
577 path: 4,
578 query: false,
579 body: true,
580 },
581 'tag_protection.update_tag_policy': { fn: api.updateTagPolicy as OpEntry['fn'], path: 3, query: false, body: true },
582 'tags.create_tag': { fn: api.createTag as OpEntry['fn'], path: 2, query: false, body: true },
583 'tags.delete_tag': { fn: api.deleteTag as OpEntry['fn'], path: 3, query: false, body: true },
584 'tags.get_tag': { fn: api.getTag as OpEntry['fn'], path: 3, query: false, body: true },
585 'tags.list_tags': { fn: api.listTags as OpEntry['fn'], path: 2, query: false, body: true },
586 'topics.create_topic': { fn: api.createTopic as OpEntry['fn'], path: 0, query: false, body: true },
587 'topics.get_topic': { fn: api.getTopic as OpEntry['fn'], path: 1, query: false, body: true },
588 'topics.list_repos_by_topic': { fn: api.listReposByTopic as OpEntry['fn'], path: 1, query: false, body: true },
589 'topics.list_topics': { fn: api.listTopics as OpEntry['fn'], path: 0, query: false, body: true },
590 'trees.get_tree': { fn: api.getTree as OpEntry['fn'], path: 3, query: false, body: true },
591 'user.create_meme': { fn: api.createMeme as OpEntry['fn'], path: 0, query: false, body: true },
592 'user.delete_avatar': { fn: api.deleteAvatar as OpEntry['fn'], path: 0, query: false, body: true },
593 'user.delete_meme': { fn: api.deleteMeme as OpEntry['fn'], path: 1, query: false, body: true },
594 'user.delete_push_subscription': {
595 fn: api.deletePushSubscription as OpEntry['fn'],
596 path: 1,
597 query: false,
598 body: true,
599 },
600 'user.get_avatar': { fn: api.getAvatar as OpEntry['fn'], path: 0, query: false, body: true },
601 'user.get_profile_detail': { fn: api.getProfileDetail as OpEntry['fn'], path: 1, query: false, body: true },
602 'user.list_memes': { fn: api.listMemes as OpEntry['fn'], path: 0, query: false, body: true },
603 'user.list_push_subscriptions': { fn: api.listPushSubscriptions as OpEntry['fn'], path: 0, query: false, body: true },
604 'user.list_saved_messages': { fn: api.listSavedMessages as OpEntry['fn'], path: 0, query: false, body: true },
605 'user.list_sessions': { fn: api.listSessions as OpEntry['fn'], path: 0, query: false, body: true },
606 'user.my_reports': { fn: api.myReports as OpEntry['fn'], path: 0, query: false, body: true },
607 'user.my_spaces': { fn: api.mySpaces as OpEntry['fn'], path: 0, query: false, body: true },
608 'user.my_suspensions': { fn: api.mySuspensions as OpEntry['fn'], path: 0, query: false, body: true },
609 'user.recent_mentions': { fn: api.recentMentions as OpEntry['fn'], path: 0, query: false, body: true },
610 'user.revoke_all_sessions': { fn: api.revokeAllSessions as OpEntry['fn'], path: 0, query: false, body: true },
611 'user.revoke_session': { fn: api.revokeSession as OpEntry['fn'], path: 1, query: false, body: true },
612 'user.save_message': { fn: api.saveMessage as OpEntry['fn'], path: 0, query: false, body: true },
613 'user.unsave_message': { fn: api.unsaveMessage as OpEntry['fn'], path: 1, query: false, body: true },
614 'user.update_meme': { fn: api.updateMeme as OpEntry['fn'], path: 1, query: false, body: true },
615 'user.upload_avatar': { fn: api.uploadAvatar as OpEntry['fn'], path: 0, query: false, body: true },
616 'user.upsert_push_subscription': {
617 fn: api.upsertPushSubscription as OpEntry['fn'],
618 path: 0,
619 query: false,
620 body: true,
621 },
622 'users.get_user': { fn: api.getUser as OpEntry['fn'], path: 1, query: false, body: true },
623 'users.get_user_avatar': { fn: api.getUserAvatar as OpEntry['fn'], path: 1, query: false, body: true },
624 'users.get_user_by_username': { fn: api.getUserByUsername as OpEntry['fn'], path: 1, query: false, body: true },
625 'users.list_users_batch': { fn: api.listUsersBatch as OpEntry['fn'], path: 0, query: false, body: true },
626}
Added view/app/src/services/storage/cache.ts View view/app/src/services/storage/cache.ts
@@ -0,0 +1,135 @@
1import { type CacheEnvelope, ENVELOPE_VERSION, type KVDriver } from './driver'
2import { createIdbDriver } from './idb-driver'
3import { createLocalStorageDriver, createNoopDriver } from './ls-driver'
4
5/**
6 * The local-first cache facade: a synchronous memory tier (L1) over a
7 * persistent tier (L2, IndexedDB with automatic localStorage fallback).
8 *
9 * Contract with the UI:
10 * - `peekCache` never blocks — it is what lets a mounting component paint
11 * stored data in the very first render;
12 * - `readCache` hydrates L1 from disk exactly once per key;
13 * - `writeCache` updates L1 synchronously and persists fire-and-forget, so a
14 * broken backend degrades into "session-only cache", never into an error;
15 * - every value belongs to the logged-in account: `setCacheOwner` wipes the
16 * whole store when a different user signs in, so cached data can never leak
17 * across accounts on a shared machine.
18 */
19
20const OWNER_KEY = '__owner'
21
22/** L1: written on every cache write, hydrated on every disk read. */
23const memory = new Map<string, CacheEnvelope>()
24
25let driverPromise: Promise<KVDriver> | null = null
26
27async function selectDriver(): Promise<KVDriver> {
28 if (typeof indexedDB !== 'undefined') {
29 try {
30 const idb = await createIdbDriver()
31 // A real roundtrip: Safari private mode happily opens a database whose
32 // transactions then all fail, and only an actual write exposes that.
33 await idb.probe()
34 return idb
35 } catch {
36 /* fall through to localStorage */
37 }
38 }
39 try {
40 const ls = createLocalStorageDriver()
41 await ls.probe()
42 return ls
43 } catch {
44 return createNoopDriver()
45 }
46}
47
48function driver(): Promise<KVDriver> {
49 driverPromise ??= selectDriver()
50 return driverPromise
51}
52
53/** Which persistent backend won the probe — surfaced for diagnostics. */
54export function cacheBackend(): Promise<string> {
55 return driver().then((d) => d.name)
56}
57
58/** Synchronous L1 read: the first-paint fast path. */
59export function peekCache<T>(key: string): CacheEnvelope<T> | undefined {
60 const hit = memory.get(key)
61 return hit && hit.v === ENVELOPE_VERSION ? (hit as CacheEnvelope<T>) : undefined
62}
63
64/** L1 read, falling back to one disk hydration. Best-effort: never rejects. */
65export async function readCache<T>(key: string): Promise<CacheEnvelope<T> | undefined> {
66 const hit = peekCache<T>(key)
67 if (hit) return hit
68 try {
69 const stored = await (await driver()).get(key)
70 if (stored) {
71 memory.set(key, stored)
72 return stored as CacheEnvelope<T>
73 }
74 } catch {
75 /* storage unavailable — the cache is best-effort by design */
76 }
77 return undefined
78}
79
80/**
81 * Update L1 synchronously and persist in the background. The merged value is
82 * what gets stored, so a custom merge (e.g. chat transcript dedupe) survives
83 * reloads exactly as the UI shows it.
84 */
85export function writeCache<T>(key: string, data: T): void {
86 const envelope: CacheEnvelope<T> = { v: ENVELOPE_VERSION, at: Date.now(), data }
87 memory.set(key, envelope as CacheEnvelope)
88 void driver()
89 .then((d) => d.set(key, envelope as CacheEnvelope))
90 .catch(() => {})
91}
92
93/**
94 * Drop one key or a whole subtree. Prefix callers should include the trailing
95 * separator (`invalidateCache('space:foo:')`) so `space:foo` itself and
96 * `space:foobar` are not caught by accident.
97 */
98export function invalidateCache(keyOrPrefix: string): void {
99 if (keyOrPrefix === '') return
100 for (const key of memory.keys()) {
101 if (key.startsWith(keyOrPrefix)) memory.delete(key)
102 }
103 void driver()
104 .then((d) => d.clearPrefix(keyOrPrefix))
105 .catch(() => {})
106}
107
108/**
109 * Bind the store to the signed-in account. Called once `me` resolves; a
110 * different owner than the recorded one means the machine changed hands (or
111 * the user re-logged as someone else), so every cached payload is wiped
112 * before the new session repopulates it.
113 */
114export async function setCacheOwner(ownerId: string): Promise<void> {
115 try {
116 const d = await driver()
117 const recorded = await d.get(OWNER_KEY)
118 if (recorded?.data === ownerId) return
119 await d.clearPrefix('')
120 memory.clear()
121 await d.set(OWNER_KEY, { v: ENVELOPE_VERSION, at: Date.now(), data: ownerId })
122 } catch {
123 /* best-effort: failing to record the owner must not break the session */
124 }
125}
126
127/** Full wipe (logout, "clear local data" affordances). */
128export async function clearLocalCache(): Promise<void> {
129 memory.clear()
130 try {
131 await (await driver()).clearPrefix('')
132 } catch {
133 /* best-effort */
134 }
135}
Added view/app/src/services/storage/driver.ts View view/app/src/services/storage/driver.ts
@@ -0,0 +1,45 @@
1/**
2 * Persistence driver contract for the local-first cache.
3 *
4 * One envelope shape travels through every layer (memory, IndexedDB,
5 * localStorage), so a value written by one driver stays readable after the
6 * page degrades to another: `v` guards against envelope changes between
7 * builds, `at` powers the prefetch throttle window.
8 */
9
10export const ENVELOPE_VERSION = 1
11
12export interface CacheEnvelope<T = unknown> {
13 /** Envelope schema version — entries from other versions are discarded. */
14 v: number
15 /** Write timestamp in ms, used for prefetch freshness checks. */
16 at: number
17 data: T
18}
19
20export interface KVDriver {
21 readonly name: 'indexeddb' | 'localstorage' | 'memory'
22 get(key: string): Promise<CacheEnvelope | undefined>
23 set(key: string, value: CacheEnvelope): Promise<void>
24 del(key: string): Promise<void>
25 /** Remove every key starting with `prefix`; `''` wipes the whole store. */
26 clearPrefix(prefix: string): Promise<void>
27 /** Real write-read-delete roundtrip; rejects when the backend is unusable. */
28 probe(): Promise<void>
29}
30
31export function withTimeout<T>(promise: Promise<T>, ms: number, reason: string): Promise<T> {
32 return new Promise<T>((resolve, reject) => {
33 const timer = setTimeout(() => reject(new Error(reason)), ms)
34 promise.then(
35 (value) => {
36 clearTimeout(timer)
37 resolve(value)
38 },
39 (err) => {
40 clearTimeout(timer)
41 reject(err)
42 },
43 )
44 })
45}
Added view/app/src/services/storage/idb-driver.ts View view/app/src/services/storage/idb-driver.ts
@@ -0,0 +1,122 @@
1import { type CacheEnvelope, ENVELOPE_VERSION, type KVDriver, withTimeout } from './driver'
2
3/**
4 * IndexedDB driver — the primary persistent backend.
5 *
6 * Deliberately dependency-free (no `idb` package): the surface is a single
7 * object store keyed by string, and the wrapper stays small enough to audit.
8 * Opening is race-guarded with a timeout because a corrupt profile or a
9 * blocking `versionchange` in another tab can make `indexedDB.open` hang
10 * forever, and the app must degrade to localStorage instead of waiting.
11 */
12
13const DB_NAME = 'kernel-store'
14const STORE_NAME = 'kv'
15const DB_VERSION = 1
16const OPEN_TIMEOUT_MS = 2000
17const PROBE_KEY = '__probe__'
18
19function wrapRequest<T>(req: IDBRequest<T>): Promise<T> {
20 return new Promise<T>((resolve, reject) => {
21 req.onsuccess = () => resolve(req.result)
22 req.onerror = () => reject(req.error)
23 })
24}
25
26function wrapTransaction(tx: IDBTransaction): Promise<void> {
27 return new Promise<void>((resolve, reject) => {
28 tx.oncomplete = () => resolve()
29 tx.onerror = () => reject(tx.error)
30 tx.onabort = () => reject(tx.error ?? new Error('idb transaction aborted'))
31 })
32}
33
34function openDatabase(): Promise<IDBDatabase> {
35 return new Promise<IDBDatabase>((resolve, reject) => {
36 let req: IDBOpenDBRequest
37 try {
38 req = indexedDB.open(DB_NAME, DB_VERSION)
39 } catch (err) {
40 // Some privacy modes throw synchronously on open.
41 reject(err)
42 return
43 }
44 req.onupgradeneeded = () => {
45 const db = req.result
46 if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME)
47 }
48 req.onsuccess = () => {
49 const db = req.result
50 // Never block a future schema upgrade from another tab.
51 db.onversionchange = () => db.close()
52 resolve(db)
53 }
54 req.onerror = () => reject(req.error)
55 req.onblocked = () => reject(new Error('indexedDB open blocked'))
56 })
57}
58
59export async function createIdbDriver(): Promise<KVDriver> {
60 const db = await withTimeout(openDatabase(), OPEN_TIMEOUT_MS, 'indexedDB open timed out')
61
62 const store = (mode: IDBTransactionMode) => db.transaction(STORE_NAME, mode).objectStore(STORE_NAME)
63
64 return {
65 name: 'indexeddb',
66
67 async get(key) {
68 const value = await wrapRequest<CacheEnvelope | undefined>(store('readonly').get(key))
69 return value && value.v === ENVELOPE_VERSION ? value : undefined
70 },
71
72 async set(key, value) {
73 const tx = db.transaction(STORE_NAME, 'readwrite')
74 tx.objectStore(STORE_NAME).put(value, key)
75 await wrapTransaction(tx)
76 },
77
78 async del(key) {
79 const tx = db.transaction(STORE_NAME, 'readwrite')
80 tx.objectStore(STORE_NAME).delete(key)
81 await wrapTransaction(tx)
82 },
83
84 async clearPrefix(prefix) {
85 const tx = db.transaction(STORE_NAME, 'readwrite')
86 const os = tx.objectStore(STORE_NAME)
87 if (prefix === '') {
88 os.clear()
89 } else {
90 // String keys sort lexicographically, so every key with the prefix
91 // lives inside [prefix, prefix + \uffff].
92 const range = IDBKeyRange.bound(prefix, `${prefix}\uffff`)
93 const cursorReq = os.openCursor(range)
94 await new Promise<void>((resolve, reject) => {
95 cursorReq.onsuccess = () => {
96 const cursor = cursorReq.result
97 if (cursor) {
98 cursor.delete()
99 cursor.continue()
100 } else {
101 resolve()
102 }
103 }
104 cursorReq.onerror = () => reject(cursorReq.error)
105 })
106 }
107 await wrapTransaction(tx)
108 },
109
110 async probe() {
111 const envelope: CacheEnvelope = { v: ENVELOPE_VERSION, at: Date.now(), data: 1 }
112 const tx = db.transaction(STORE_NAME, 'readwrite')
113 tx.objectStore(STORE_NAME).put(envelope, PROBE_KEY)
114 await wrapTransaction(tx)
115 const read = await wrapRequest<CacheEnvelope | undefined>(store('readonly').get(PROBE_KEY))
116 const tx2 = db.transaction(STORE_NAME, 'readwrite')
117 tx2.objectStore(STORE_NAME).delete(PROBE_KEY)
118 await wrapTransaction(tx2)
119 if (read?.data !== 1) throw new Error('indexedDB probe roundtrip failed')
120 },
121 }
122}
Added view/app/src/services/storage/index.ts View view/app/src/services/storage/index.ts
@@ -0,0 +1,14 @@
1// Public API of the shared/storage segment — the local-first persistence
2// behind every cached page dataset: IndexedDB with an automatic localStorage
3// fallback, fronted by a synchronous memory tier. Consumers import from
4// '@app/services/storage', never from deeper files.
5export {
6 cacheBackend,
7 clearLocalCache,
8 invalidateCache,
9 peekCache,
10 readCache,
11 setCacheOwner,
12 writeCache,
13} from './cache'
14export { type CacheEnvelope, ENVELOPE_VERSION, type KVDriver } from './driver'
Added view/app/src/services/storage/ls-driver.ts View view/app/src/services/storage/ls-driver.ts
@@ -0,0 +1,83 @@
1import { type CacheEnvelope, ENVELOPE_VERSION, type KVDriver } from './driver'
2
3/**
4 * localStorage driver — the automatic fallback when IndexedDB is unavailable
5 * (privacy modes, blocked storage, corrupt profiles).
6 *
7 * Two constraints shape it:
8 * - the ~5MB quota is shared with the whole origin, so a single oversized
9 * value (a big repo tree, a long transcript seed) is skipped instead of
10 * evicting everything else;
11 * - every failure is contained here and surfaces as a rejected promise, which
12 * the cache facade treats as "this write did not happen" — the app must run
13 * identically with a full, empty or broken storage backend.
14 */
15
16const LS_PREFIX = 'kernel-cache:'
17const PROBE_KEY = '__probe__'
18/** Above this serialized size a value is not worth risking the quota. */
19const MAX_VALUE_BYTES = 1_500_000
20
21export function createLocalStorageDriver(): KVDriver {
22 const storageKey = (key: string) => LS_PREFIX + key
23
24 const parse = (raw: string | null): CacheEnvelope | undefined => {
25 if (raw === null) return undefined
26 try {
27 const value = JSON.parse(raw) as CacheEnvelope
28 return value && value.v === ENVELOPE_VERSION ? value : undefined
29 } catch {
30 return undefined
31 }
32 }
33
34 return {
35 name: 'localstorage',
36
37 async get(key) {
38 return parse(localStorage.getItem(storageKey(key)))
39 },
40
41 async set(key, value) {
42 const raw = JSON.stringify(value)
43 if (raw.length > MAX_VALUE_BYTES) return
44 localStorage.setItem(storageKey(key), raw)
45 },
46
47 async del(key) {
48 localStorage.removeItem(storageKey(key))
49 },
50
51 async clearPrefix(prefix) {
52 const full = storageKey(prefix)
53 const doomed: string[] = []
54 for (let i = 0; i < localStorage.length; i++) {
55 const k = localStorage.key(i)
56 if (k !== null && k.startsWith(full)) doomed.push(k)
57 }
58 for (const k of doomed) localStorage.removeItem(k)
59 },
60
61 async probe() {
62 const probeStorage = storageKey(PROBE_KEY)
63 localStorage.setItem(probeStorage, '1')
64 const ok = localStorage.getItem(probeStorage) === '1'
65 localStorage.removeItem(probeStorage)
66 if (!ok) throw new Error('localStorage probe roundtrip failed')
67 },
68 }
69}
70
71/** Last resort when every persistent backend is blocked: L1 memory only. */
72export function createNoopDriver(): KVDriver {
73 return {
74 name: 'memory',
75 async get() {
76 return undefined
77 },
78 async set() {},
79 async del() {},
80 async clearPrefix() {},
81 async probe() {},
82 }
83}
Added view/app/src/services/voice/host.tsx View view/app/src/services/voice/host.tsx
@@ -0,0 +1,38 @@
1/**
2 * The React edge of the voice session.
3 *
4 * `voiceSession` owns the single tab-wide Room (see session.ts), so the
5 * vendored components are fed that same instance through `RoomContext` instead
6 * of the official `LiveKitRoom`, which would tie the connection to a route.
7 * `VoiceHost` mounts the parts that must keep working while the user navigates
8 * away from the channel: remote audio playback and the connection toast.
9 */
10
11import { ConnectionStateToast, RoomAudioRenderer, RoomContext } from '@app/vendor/livekit'
12import type { ReactNode } from 'react'
13import { useVoiceSession } from './react'
14import { voiceSession } from './session'
15
16/** Injects the live Room for any subtree that renders official components. */
17export function VoiceProvider({ children }: { children: ReactNode }) {
18 const session = useVoiceSession()
19 const room = session.roomId ? voiceSession.getRoom() : null
20 if (!room) return null
21 return <RoomContext.Provider value={room}>{children}</RoomContext.Provider>
22}
23
24/**
25 * Route-independent audio + connection feedback. Mount once, at the root: the
26 * toast carries lk- classes, so this wrapper owns the theme attribute that
27 * scopes the vendored stylesheet.
28 */
29export function VoiceHost() {
30 return (
31 <VoiceProvider>
32 <div className="voice-host" data-lk-theme="default">
33 <RoomAudioRenderer />
34 <ConnectionStateToast />
35 </div>
36 </VoiceProvider>
37 )
38}
Added view/app/src/services/voice/index.ts View view/app/src/services/voice/index.ts
@@ -0,0 +1,14 @@
1// Public API of the shared/voice segment.
2
3export { VoiceHost, VoiceProvider } from './host'
4export { useVoiceSession } from './react'
5export type {
6 JoinOptions,
7 StoredSession,
8 VoiceCredentialSource,
9 VoiceCredentials,
10 VoiceSessionSnapshot,
11 VoiceStatus,
12} from './session'
13export { storedVoiceSession, voiceSession } from './session'
14export { rememberedVolume, rememberVolume } from './volumes'
Added view/app/src/services/voice/react.ts View view/app/src/services/voice/react.ts
@@ -0,0 +1,10 @@
1import { useCallback, useSyncExternalStore } from 'react'
2import { type VoiceSessionSnapshot, voiceSession } from './session'
3
4const INITIAL: VoiceSessionSnapshot = voiceSession.getSnapshot()
5
6export function useVoiceSession(): VoiceSessionSnapshot {
7 const subscribe = useCallback((onChange: () => void) => voiceSession.subscribe(() => onChange()), [])
8 const getSnapshot = useCallback(() => voiceSession.getSnapshot(), [])
9 return useSyncExternalStore(subscribe, getSnapshot, () => INITIAL)
10}
Added view/app/src/services/voice/session.ts View view/app/src/services/voice/session.ts
@@ -0,0 +1,709 @@
1/**
2 * The app-wide voice session: exactly one LiveKit Room for the whole tab,
3 * mirroring the realtime hub's "module singleton, not a React provider"
4 * decision. It outlives every route, so switching to a repository or board
5 * keeps the call up and the global call bar can drive it from anywhere.
6 *
7 * Design notes:
8 * - This file owns *connection* only. Rendering and remote-audio playback live
9 * in the vendored `@livekit/components-react` tree (see `host.tsx`), which is
10 * fed the same Room through `RoomContext`.
11 * - A refresh restores the room from sessionStorage, but always muted: the
12 * browser will not autoplay audio without a gesture, and silently rejoining a
13 * call with an open microphone is worse than one extra click.
14 * - Track/participant changes are published as a single immutable snapshot so
15 * `useSyncExternalStore` consumers re-render only on real state changes.
16 * Per-participant state (speaking, mute, quality, volume) is deliberately NOT
17 * mirrored here: it is read from the Room by the official hooks instead, so
18 * there is exactly one source of truth for media state.
19 * - Failures are values (notice + detail), never throws: the room view stays
20 * usable when LiveKit is down, exactly like the chat socket's status banner.
21 * - `livekit-client@2.22` cannot swap the token of a live connection, so an
22 * expired credential is recovered by rejoining with a freshly issued one.
23 */
24
25import { t } from '@app/i18n'
26import {
27 ConnectionError,
28 ConnectionState,
29 DeviceUnsupportedError,
30 DisconnectReason,
31 LivekitError,
32 type LocalParticipant,
33 MediaDeviceFailure,
34 type RemoteParticipant,
35 Room,
36 RoomEvent,
37 type ScreenShareCaptureOptions,
38 Track,
39} from 'livekit-client'
40import { rememberedVolume } from './volumes'
41
42/** Persisted so a refresh can rejoin the same channel. */
43const STORAGE_KEY = 'gitdata.voice.session'
44/** Persisted panel toggles: they outlive the call, like the call-bar position. */
45const DEAFENED_KEY = 'gitdata.voice.deafened'
46const MIC_MUTED_KEY = 'gitdata.voice.micMuted'
47
48function readFlag(key: string): boolean {
49 try {
50 return localStorage.getItem(key) === '1'
51 } catch {
52 return false
53 }
54}
55
56function writeFlag(key: string, value: boolean): void {
57 try {
58 if (value) localStorage.setItem(key, '1')
59 else localStorage.removeItem(key)
60 } catch {
61 // A toggle that cannot persist still works for this session.
62 }
63}
64
65/**
66 * Backoff for the automatic rejoin. Every attempt pulls a *new* token, which is
67 * what makes long calls survive the token TTL.
68 */
69const REJOIN_DELAYS_MS = [1_000, 3_000]
70
71export type VoiceStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'failed'
72
73export interface VoiceSessionSnapshot {
74 status: VoiceStatus
75 roomId: string | null
76 /** Channel name and owning space, so the global bar can label and link back. */
77 roomLabel: string | null
78 spaceName: string | null
79 /** Set when a moderator removed this participant: the bar explains it. */
80 notice: string | null
81 /** Raw SDK/browser message, shown behind the "connection details" disclosure. */
82 noticeDetail: string | null
83 micEnabled: boolean
84 cameraEnabled: boolean
85 screenShareEnabled: boolean
86 /**
87 * Deafened: every remote track is silenced locally, and the microphone follows
88 * Discord's rule of muting for as long as it lasts.
89 */
90 deafened: boolean
91 /** Muted-by-choice while not in a call: the state the next join starts from. */
92 micMuted: boolean
93 /** False until the browser has been allowed to play remote audio. */
94 audioPlaybackAllowed: boolean
95 /** Local speak indicator only; remote tiles read the Room directly. */
96 speaking: boolean
97 /** True once a publish permission flip landed (Stage approval). */
98 canPublish: boolean
99 isStage: boolean
100 hasRaisedHand: boolean
101 /** Host revoked our microphone during this call (distinct from Stage queue). */
102 mutedByHost: boolean
103 /** An automatic rejoin is scheduled/in flight. */
104 rejoining: boolean
105 /** Auto-rejoin attempts are exhausted: only a manual action can recover. */
106 needsManualRejoin: boolean
107}
108
109export interface StoredSession {
110 roomId: string
111 roomLabel?: string
112 spaceName?: string
113}
114
115/** Credentials for one join. Supplied by the app layer to keep `shared` free of API imports. */
116export interface VoiceCredentials {
117 url: string
118 token: string
119 canPublish: boolean
120 isStage: boolean
121}
122
123export type VoiceCredentialSource = (roomId: string) => Promise<VoiceCredentials>
124
125export interface JoinOptions extends VoiceCredentials {
126 roomId: string
127 /** Channel name for the global call bar. */
128 roomLabel?: string
129 /** Owning space slug, so "回到语音频道" can rebuild the route. */
130 spaceName?: string
131 /** Voice channels join with the microphone on; Stage audiences stay muted. */
132 startWithMic: boolean
133 /** Chosen in the device sheet; omitted means "whatever the browser prefers". */
134 audioDeviceId?: string | null
135 videoDeviceId?: string | null
136 audioOutputDeviceId?: string | null
137}
138
139const INITIAL: VoiceSessionSnapshot = {
140 status: 'idle',
141 roomId: null,
142 roomLabel: null,
143 spaceName: null,
144 notice: null,
145 noticeDetail: null,
146 micEnabled: false,
147 cameraEnabled: false,
148 screenShareEnabled: false,
149 deafened: false,
150 micMuted: false,
151 audioPlaybackAllowed: true,
152 speaking: false,
153 canPublish: true,
154 isStage: false,
155 hasRaisedHand: false,
156 mutedByHost: false,
157 rejoining: false,
158 needsManualRejoin: false,
159}
160
161type Listener = (snapshot: VoiceSessionSnapshot) => void
162
163class VoiceSession {
164 private room: Room | null = null
165 private snapshot: VoiceSessionSnapshot = {
166 ...INITIAL,
167 deafened: readFlag(DEAFENED_KEY),
168 micMuted: readFlag(MIC_MUTED_KEY),
169 }
170 /** Microphone state to restore when the deafen toggle is switched back off. */
171 private micBeforeDeafen = false
172 private readonly listeners = new Set<Listener>()
173 private readonly participantNames = new Map<string, string>()
174 /** Distinguishes a user-initiated teardown from an ICE/signal drop. */
175 private leaving = false
176 private credentialSource: VoiceCredentialSource | null = null
177 private rejoinTimer = 0
178 private rejoinAttempt = 0
179 /** Last join parameters, replayed verbatim on an automatic rejoin. */
180 private lastJoin: JoinOptions | null = null
181 /**
182 * The SDK disconnects with a client-initiated reason when the page is being
183 * torn down, which looks exactly like a hang-up. A reload must still restore
184 * the call, so the teardown is observed separately.
185 */
186 private unloading = false
187
188 getSnapshot(): VoiceSessionSnapshot {
189 return this.snapshot
190 }
191
192 subscribe(listener: Listener): () => void {
193 this.listeners.add(listener)
194 return () => {
195 this.listeners.delete(listener)
196 }
197 }
198
199 /** Display name for a participant identity (falls back to the raw identity). */
200 nameOf(identity: string): string {
201 return this.participantNames.get(identity) ?? identity
202 }
203
204 /** Lets the app layer hand over a token fetcher without `shared` importing `entities`. */
205 setCredentialSource(source: VoiceCredentialSource): void {
206 this.credentialSource = source
207 if (typeof window === 'undefined' || (window as Window & { lkUnloadBound?: boolean }).lkUnloadBound) return
208 ;(window as Window & { lkUnloadBound?: boolean }).lkUnloadBound = true
209 const onHide = () => {
210 this.unloading = true
211 }
212 const onShow = () => {
213 this.unloading = false
214 }
215 window.addEventListener('pagehide', onHide)
216 window.addEventListener('pageshow', onShow)
217 }
218
219 private patch(updates: Partial<VoiceSessionSnapshot>): void {
220 const next = { ...this.snapshot, ...updates }
221 let changed = false
222 for (const key of Object.keys(updates) as (keyof VoiceSessionSnapshot)[]) {
223 if (this.snapshot[key] !== next[key]) {
224 changed = true
225 break
226 }
227 }
228 if (!changed) return
229 this.snapshot = next
230 for (const listener of [...this.listeners]) listener(next)
231 }
232
233 /** True when this tab is currently inside roomId. */
234 isIn(roomId: string): boolean {
235 return this.snapshot.roomId === roomId && this.snapshot.status !== 'idle'
236 }
237
238 /** The room a page load should offer to restore, if any. */
239 static stored(): StoredSession | null {
240 try {
241 const raw = sessionStorage.getItem(STORAGE_KEY)
242 if (!raw) return null
243 const parsed = JSON.parse(raw) as StoredSession
244 return parsed.roomId ? parsed : null
245 } catch {
246 return null
247 }
248 }
249
250 private persist(roomId: string | null): void {
251 try {
252 if (!roomId) {
253 sessionStorage.removeItem(STORAGE_KEY)
254 return
255 }
256 const payload: StoredSession = {
257 roomId,
258 ...(this.snapshot.roomLabel ? { roomLabel: this.snapshot.roomLabel } : {}),
259 ...(this.snapshot.spaceName ? { spaceName: this.snapshot.spaceName } : {}),
260 }
261 sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
262 } catch {
263 // Private mode / disabled storage: the call still works, only the
264 // refresh restore is lost.
265 }
266 }
267
268 async join(options: JoinOptions): Promise<void> {
269 if (this.room && this.snapshot.roomId === options.roomId) return
270 if (this.room) await this.leave()
271 this.cancelRejoin()
272 const room = new Room({ adaptiveStream: true, dynacast: true })
273 this.room = room
274 this.leaving = false
275 this.lastJoin = options
276 this.patch({
277 status: this.rejoinAttempt > 0 ? 'reconnecting' : 'connecting',
278 roomId: options.roomId,
279 roomLabel: options.roomLabel ?? null,
280 spaceName: options.spaceName ?? null,
281 notice: null,
282 noticeDetail: null,
283 canPublish: options.canPublish,
284 isStage: options.isStage,
285 needsManualRejoin: false,
286 micEnabled: false,
287 cameraEnabled: false,
288 screenShareEnabled: false,
289 speaking: false,
290 })
291 this.bind(room)
292
293 try {
294 await room.connect(options.url, options.token)
295 this.patch({ status: 'connected' })
296 this.persist(options.roomId)
297 if (options.audioOutputDeviceId) {
298 await this.applyOutputDevice(room, options.audioOutputDeviceId)
299 }
300 // Deafened survives a reload, so a restored call comes back silent; the
301 // microphone stays off until the toggle is switched back.
302 if (this.snapshot.deafened) this.applyDeafened()
303 if (options.canPublish && options.startWithMic && !this.snapshot.deafened) {
304 await this.setMicrophone(true, options.audioDeviceId)
305 }
306 if (options.canPublish && options.videoDeviceId && options.startWithMic) {
307 await this.setCamera(true, options.videoDeviceId)
308 }
309 this.rejoinAttempt = 0
310 } catch (error) {
311 const described = describeVoiceFailure(error)
312 this.releaseRoom(room)
313 this.patch({
314 status: 'failed',
315 roomId: null,
316 micEnabled: false,
317 cameraEnabled: false,
318 screenShareEnabled: false,
319 speaking: false,
320 rejoining: false,
321 notice: this.snapshot.notice ?? described.notice,
322 noticeDetail: this.snapshot.noticeDetail ?? described.detail,
323 })
324 throw error
325 }
326 }
327
328 /** Switch the speaker/headset without a reconnect. */
329 private async applyOutputDevice(room: Room, deviceId: string): Promise<void> {
330 try {
331 await room.switchActiveDevice('audiooutput', deviceId)
332 } catch {
333 // Unsupported browser (Safari): the default route keeps working.
334 }
335 }
336
337 /** Route an output device change requested from the settings menu. */
338 async setAudioOutputDevice(deviceId: string): Promise<void> {
339 const room = this.room
340 if (!room) return
341 try {
342 await room.switchActiveDevice('audiooutput', deviceId)
343 if (this.lastJoin) this.lastJoin = { ...this.lastJoin, audioOutputDeviceId: deviceId }
344 } catch (error) {
345 const described = describeVoiceFailure(error)
346 this.patch({ notice: described.notice, noticeDetail: described.detail })
347 }
348 }
349
350 private releaseRoom(room: Room): void {
351 if (this.room === room) this.room = null
352 }
353
354 private bind(room: Room): void {
355 let couldPublish = this.snapshot.canPublish
356 const refreshSelf = () => {
357 if (this.room !== room) return
358 const local = room.localParticipant
359 this.captureNames([...room.remoteParticipants.values()], local)
360 const canPublish = local.permissions?.canPublish ?? couldPublish
361 if (couldPublish && !canPublish) {
362 // A mid-call flip is a moderation action, so say who did what.
363 this.patch({ mutedByHost: !this.snapshot.isStage, notice: t('chat.voice.mutedByHost') })
364 } else if (!couldPublish && canPublish) {
365 this.patch({ mutedByHost: false, notice: t('chat.voice.unmutedByHost') })
366 }
367 couldPublish = canPublish
368 this.patch({
369 micEnabled: local.isMicrophoneEnabled,
370 cameraEnabled: local.isCameraEnabled,
371 screenShareEnabled: local.isScreenShareEnabled,
372 canPublish,
373 speaking: local.isSpeaking,
374 })
375 }
376
377 room
378 .on(RoomEvent.ConnectionStateChanged, (state: ConnectionState) => {
379 if (state === ConnectionState.Connected) this.patch({ status: 'connected' })
380 else if (state === ConnectionState.Reconnecting) this.patch({ status: 'reconnecting' })
381 })
382 .on(RoomEvent.Reconnected, () => {
383 this.cancelRejoin()
384 this.rejoinAttempt = 0
385 this.patch({ status: 'connected', notice: null, noticeDetail: null, rejoining: false })
386 })
387 .on(RoomEvent.MediaDevicesError, (error: Error) => {
388 const described = describeVoiceFailure(error)
389 this.patch({ notice: described.notice, noticeDetail: described.detail })
390 })
391 .on(RoomEvent.ParticipantConnected, (participant) => {
392 // Somebody arriving while deafened must be silent too. Their microphone
393 // track may not exist yet, and the participant-level volume is what the
394 // SDK remembers until it does.
395 if (this.snapshot.deafened) {
396 participant.setVolume(0)
397 participant.setVolume(0, Track.Source.ScreenShareAudio)
398 }
399 refreshSelf()
400 })
401 .on(RoomEvent.ParticipantDisconnected, () => refreshSelf())
402 .on(RoomEvent.LocalTrackPublished, () => refreshSelf())
403 .on(RoomEvent.LocalTrackUnpublished, () => refreshSelf())
404 .on(RoomEvent.TrackMuted, () => refreshSelf())
405 .on(RoomEvent.TrackUnmuted, () => refreshSelf())
406 .on(RoomEvent.ActiveSpeakersChanged, () => refreshSelf())
407 .on(RoomEvent.ParticipantPermissionsChanged, () => refreshSelf())
408 .on(RoomEvent.MediaDevicesChanged, () => refreshSelf())
409 .on(RoomEvent.AudioPlaybackStatusChanged, () => {
410 this.patch({ audioPlaybackAllowed: room.canPlaybackAudio })
411 })
412 .on(RoomEvent.Disconnected, (reason?: DisconnectReason) => {
413 // The SDK also disconnects on pagehide, and that must NOT clear the
414 // stored session - the next page load is expected to restore the call.
415 // Only reasons that make a return impossible drop the stored room.
416 const kicked = reason === DisconnectReason.PARTICIPANT_REMOVED
417 const leftForGood =
418 kicked || reason === DisconnectReason.ROOM_DELETED || reason === DisconnectReason.DUPLICATE_IDENTITY
419 const hungUp = this.leaving || reason === DisconnectReason.CLIENT_INITIATED
420 const intentional = hungUp && !this.unloading
421 this.releaseRoom(room)
422 const retainedNotice = this.snapshot.notice
423 const retainedDetail = this.snapshot.noticeDetail
424 // An explicit hang-up must not come back on the next page load, and it
425 // must not be auto-retried either - the official DisconnectButton
426 // disconnects without going through leave().
427 if (leftForGood || intentional) {
428 this.lastJoin = null
429 this.persist(null)
430 }
431 this.patch({
432 status: intentional || leftForGood ? 'idle' : 'failed',
433 roomId: leftForGood ? null : this.snapshot.roomId,
434 roomLabel: leftForGood ? null : this.snapshot.roomLabel,
435 spaceName: leftForGood ? null : this.snapshot.spaceName,
436 micEnabled: false,
437 cameraEnabled: false,
438 screenShareEnabled: false,
439 speaking: false,
440 notice: kicked
441 ? t('chat.voice.notice.kicked')
442 : reason === DisconnectReason.DUPLICATE_IDENTITY
443 ? t('chat.voice.notice.duplicate')
444 : retainedNotice
445 ? retainedNotice
446 : intentional
447 ? null
448 : t('chat.voice.reconnecting'),
449 noticeDetail: retainedDetail ?? (reason ? String(reason) : null),
450 })
451 if (!intentional && !leftForGood) this.scheduleRejoin()
452 })
453 refreshSelf()
454 }
455
456 private cancelRejoin(): void {
457 if (this.rejoinTimer) {
458 clearTimeout(this.rejoinTimer)
459 this.rejoinTimer = 0
460 }
461 this.patch({ rejoining: false })
462 }
463
464 /** Rejoin with a freshly issued token; gives up after REJOIN_DELAYS_MS runs out. */
465 private scheduleRejoin(): void {
466 const last = this.lastJoin
467 if (!last || !this.credentialSource || this.rejoinTimer) return
468 if (this.rejoinAttempt >= REJOIN_DELAYS_MS.length) {
469 this.patch({
470 status: 'failed',
471 rejoining: false,
472 needsManualRejoin: true,
473 notice: t('chat.voice.reconnectFailed'),
474 })
475 return
476 }
477 const delay = REJOIN_DELAYS_MS[this.rejoinAttempt]
478 this.rejoinAttempt += 1
479 this.patch({ rejoining: true, status: 'reconnecting' })
480 this.rejoinTimer = window.setTimeout(() => {
481 this.rejoinTimer = 0
482 void this.rejoin()
483 }, delay)
484 }
485
486 /** Automatic path (after the backoff) and manual "rejoin" button share this. */
487 async rejoin(): Promise<boolean> {
488 const last = this.lastJoin
489 const source = this.credentialSource
490 if (!last || !source) return false
491 this.patch({ notice: t('chat.voice.reconnecting'), rejoining: true })
492 try {
493 const credentials = await source(last.roomId)
494 await this.join({ ...last, ...credentials })
495 this.cancelRejoin()
496 this.patch({ rejoining: false, needsManualRejoin: false })
497 return true
498 } catch (error) {
499 const described = describeVoiceFailure(error)
500 this.patch({
501 notice: this.snapshot.notice ?? described.notice,
502 noticeDetail: this.snapshot.noticeDetail ?? described.detail,
503 })
504 this.scheduleRejoin()
505 return false
506 }
507 }
508
509 private captureNames(remotes: RemoteParticipant[], local: LocalParticipant | null): void {
510 for (const participant of remotes) {
511 if (participant.name) this.participantNames.set(participant.identity, participant.name)
512 }
513 if (local?.name) this.participantNames.set(local.identity, local.name)
514 }
515
516 async leave(): Promise<void> {
517 const room = this.room
518 this.leaving = true
519 this.cancelRejoin()
520 this.rejoinAttempt = 0
521 this.lastJoin = null
522 this.room = null
523 this.persist(null)
524 this.patch({
525 status: 'idle',
526 roomId: null,
527 roomLabel: null,
528 spaceName: null,
529 micEnabled: false,
530 cameraEnabled: false,
531 screenShareEnabled: false,
532 speaking: false,
533 mutedByHost: false,
534 needsManualRejoin: false,
535 rejoining: false,
536 notice: null,
537 noticeDetail: null,
538 })
539 if (!room) return
540 try {
541 await room.disconnect()
542 } catch {
543 // Already gone; nothing useful to report.
544 }
545 }
546
547 async setMicrophone(enabled: boolean, deviceId?: string | null): Promise<void> {
548 const room = this.room
549 if (!room) return
550 try {
551 await room.localParticipant.setMicrophoneEnabled(
552 enabled,
553 deviceId ? { deviceId: { exact: deviceId } } : undefined,
554 )
555 this.patch({ micEnabled: enabled })
556 if (this.lastJoin) this.lastJoin = { ...this.lastJoin, audioDeviceId: deviceId ?? null }
557 } catch (error) {
558 const described = describeVoiceFailure(error)
559 this.patch({ notice: described.notice, noticeDetail: described.detail })
560 }
561 }
562
563 async setCamera(enabled: boolean, deviceId?: string | null): Promise<void> {
564 const room = this.room
565 if (!room) return
566 try {
567 await room.localParticipant.setCameraEnabled(enabled, deviceId ? { deviceId: { exact: deviceId } } : undefined)
568 this.patch({ cameraEnabled: enabled })
569 if (this.lastJoin) this.lastJoin = { ...this.lastJoin, videoDeviceId: deviceId ?? null }
570 } catch (error) {
571 const described = describeVoiceFailure(error)
572 this.patch({ notice: described.notice, noticeDetail: described.detail })
573 }
574 }
575
576 /**
577 * `audio` decides whether the tab/window/system sound rides along; cancelling
578 * the browser picker is not a failure and is reported as such.
579 */
580 async setScreenShare(enabled: boolean, options?: ScreenShareCaptureOptions): Promise<boolean> {
581 const room = this.room
582 if (!room) return false
583 try {
584 await room.localParticipant.setScreenShareEnabled(enabled, { audio: true, ...options })
585 this.patch({ screenShareEnabled: enabled })
586 return true
587 } catch (error) {
588 if (isUserCancellation(error)) {
589 this.patch({ notice: t('chat.voice.shareCancelled'), noticeDetail: null })
590 } else {
591 const described = describeVoiceFailure(error)
592 this.patch({ notice: described.notice, noticeDetail: described.detail })
593 }
594 return false
595 }
596 }
597
598 /**
599 * Headphones toggle. Silences every remote track - microphone and screen-share
600 * audio alike, including tracks that subscribe later - and mutes the microphone
601 * for as long as it lasts, which is what Discord's deafen does. Outside a call
602 * it only records the preference the next join applies.
603 */
604 async setDeafened(enabled: boolean): Promise<void> {
605 if (this.snapshot.deafened === enabled) return
606 const wasMicOn = this.snapshot.micEnabled
607 if (enabled) this.micBeforeDeafen = wasMicOn
608 this.patch({ deafened: enabled })
609 writeFlag(DEAFENED_KEY, enabled)
610 if (!this.room) return
611 this.applyDeafened()
612 if (enabled) {
613 if (wasMicOn && this.snapshot.canPublish) await this.setMicrophone(false)
614 } else if (this.micBeforeDeafen && this.snapshot.canPublish) {
615 await this.setMicrophone(true)
616 }
617 }
618
619 /** The microphone toggle outside a call: remembered until the next join. */
620 setMicMuted(muted: boolean): void {
621 this.patch({ micMuted: muted })
622 writeFlag(MIC_MUTED_KEY, muted)
623 }
624
625 /** Push the deafen flag onto every remote track currently in the room. */
626 private applyDeafened(): void {
627 const room = this.room
628 if (!room) return
629 const roomId = this.snapshot.roomId
630 for (const participant of room.remoteParticipants.values()) {
631 participant.setVolume(this.snapshot.deafened ? 0 : rememberedVolume(roomId, participant.identity))
632 participant.setVolume(this.snapshot.deafened ? 0 : 1, Track.Source.ScreenShareAudio)
633 }
634 }
635
636 /** Browsers block remote audio until a gesture; the call bar calls this. */
637 async resumeAudio(): Promise<void> {
638 const room = this.room
639 if (!room) return
640 try {
641 await room.startAudio()
642 this.patch({ audioPlaybackAllowed: room.canPlaybackAudio })
643 } catch {
644 this.patch({ notice: t('chat.voice.notice.audioBlocked') })
645 }
646 }
647
648 markHandRaised(raised: boolean): void {
649 this.patch({ hasRaisedHand: raised })
650 }
651
652 clearNotice(): void {
653 this.patch({ notice: null, noticeDetail: null })
654 }
655
656 /** LiveKit Room handle for the voice host (RoomContext + RoomAudioRenderer). */
657 getRoom(): Room | null {
658 return this.room
659 }
660}
661
662/** The user dismissed the picker; nothing went wrong. */
663function isUserCancellation(error: unknown): boolean {
664 return error instanceof Error && error.name === 'AbortError'
665}
666
667interface VoiceFailure {
668 notice: string
669 detail: string | null
670}
671
672/**
673 * Classify by the SDK's error types and the browser's `DOMException.name`
674 * instead of matching English message substrings, which break whenever the SDK
675 * rewords a message.
676 */
677function describeVoiceFailure(error: unknown): VoiceFailure {
678 const detail = error instanceof Error ? error.message : String(error ?? '')
679 if (error instanceof Error && error.name === 'NotAllowedError') {
680 return { notice: t('chat.voice.notice.permissionDenied'), detail: detail || null }
681 }
682 const deviceFailure = error instanceof Error ? MediaDeviceFailure.getFailure(error) : undefined
683 if (deviceFailure === MediaDeviceFailure.PermissionDenied) {
684 return { notice: t('chat.voice.notice.permissionDenied'), detail: detail || null }
685 }
686 if (deviceFailure === MediaDeviceFailure.NotFound || (error instanceof Error && error.name === 'NotFoundError')) {
687 return { notice: t('chat.voice.notice.deviceMissing'), detail: detail || null }
688 }
689 if (deviceFailure === MediaDeviceFailure.DeviceInUse) {
690 return { notice: t('chat.voice.notice.deviceInUse'), detail: detail || null }
691 }
692 if (error instanceof DeviceUnsupportedError) {
693 return { notice: t('chat.voice.notice.browserUnsupported'), detail: detail || null }
694 }
695 if (/token|unauthori[sz]ed|forbidden/i.test(detail)) {
696 return { notice: t('chat.voice.notice.tokenInvalid'), detail: detail || null }
697 }
698 if (error instanceof ConnectionError || error instanceof LivekitError) {
699 return { notice: t('chat.voice.notice.mediaUnreachable'), detail: detail || null }
700 }
701 return { notice: t('chat.voice.notice.connectFailed'), detail: detail || null }
702}
703
704export const voiceSession = new VoiceSession()
705
706/** The persisted call for this tab, if any - the refresh-restore entry point. */
707export function storedVoiceSession(): StoredSession | null {
708 return VoiceSession.stored()
709}
Added view/app/src/services/voice/volumes.ts View view/app/src/services/voice/volumes.ts
@@ -0,0 +1,27 @@
1/**
2 * Per-member output volume lives in the browser, not on the server: the tile menu
3 * writes it while the user drags the slider, and the deafen toggle reads the same
4 * keys back when the sound comes on again. Both go through this module so the
5 * storage format cannot drift between them.
6 */
7const VOLUME_PREFIX = 'gitdata.voice.volume.'
8
9/** Remembered volume for one member; 1 when nothing usable is stored. */
10export function rememberedVolume(roomId: string | null, identity: string): number {
11 if (!roomId) return 1
12 try {
13 const raw = Number(localStorage.getItem(VOLUME_PREFIX + roomId + '.' + identity))
14 return Number.isFinite(raw) && raw > 0 ? raw : 1
15 } catch {
16 return 1
17 }
18}
19
20/** Persist one member's volume. Best effort: memory is a convenience. */
21export function rememberVolume(roomId: string, identity: string, volume: number): void {
22 try {
23 localStorage.setItem(VOLUME_PREFIX + roomId + '.' + identity, String(volume))
24 } catch {
25 // Volume memory is a convenience, never a requirement.
26 }
27}